Skip to content

notebooks

tit.server.notebooks

Notebook files under code/ti-toolbox/notebooks/ — read, write, list.

The .ipynb on disk is the document (SUNA docs/dev/ARCHITECTURE.md §16.3). A notebook the app opens and saves untouched must produce an empty git diff, or every notebook in a project becomes a merge conflict the moment two tools disagree about whitespace. SUNA reimplements nbformat's serializer in TypeScript because its notebook model lives in the renderer; here the file never leaves the container, so nbformat itself does the reading and writing and the question does not arise.

Everything lives in one directory, <project>/code/ti-toolbox/notebooks, which is also where the pipeline's exported notebooks land. Names are a single path segment: there are no subdirectories, because a flat list is what the UI shows and a traversal is the only thing a name could otherwise buy.

The worked example

examples/example_workflow.ipynb is a byte-for-byte copy of the packaged tit/server/examples/example_workflow.ipynb (the repository's examples/notebooks/example_workflow.ipynb is a symlink to that same file, so there is exactly one source). :func:seed_example writes it on the first listing and records the packaged file's sha256 in examples/.seeded. On later listings it re-copies the notebook only when the packaged file has changed and the project's copy still matches the hash that was recorded — a copy the user edited is theirs and is never overwritten. A deleted example is not given back: deleting it writes deleted into the stamp instead of a hash.

NotebookError

NotebookError(code: str, message: str)

Bases: ValueError

A notebook could not be named, found, read or written.

Source code in tit/server/notebooks.py
def __init__(self, code: str, message: str) -> None:
    super().__init__(message)
    self.code = code
    self.message = message

NotebookEntry dataclass

NotebookEntry(name: str, size: int, modified: float, example: bool = False)

One notebook as the list shows it.

notebooks_dir

notebooks_dir(project_root: str | Path) -> Path

The notebook directory for a project (not created).

Source code in tit/server/notebooks.py
def notebooks_dir(project_root: str | Path) -> Path:
    """The notebook directory for a project (not created)."""
    root = os.path.realpath(project_root)
    directory = os.path.realpath(os.path.join(root, NOTEBOOK_SUBDIR))
    if directory == root or directory.startswith(root.rstrip(os.sep) + os.sep):
        return Path(directory)
    raise NotebookError("bad-name", "Notebook directory resolves outside the project.")

normalise_name

normalise_name(name: str) -> str

A caller's name as the file name it maps to, or raise.

analysis, analysis.ipynb, Analysis 2.ipynb and examples/example_workflow.ipynb are all fine; ../escape, a/b and .hidden are not. The examples/ prefix is the only directory a name may carry (:data:EXAMPLES_DIR).

Source code in tit/server/notebooks.py
def normalise_name(name: str) -> str:
    """A caller's name as the file name it maps to, or raise.

    ``analysis``, ``analysis.ipynb``, ``Analysis 2.ipynb`` and
    ``examples/example_workflow.ipynb`` are all fine; ``../escape``, ``a/b`` and
    ``.hidden`` are not. The ``examples/`` prefix is the only directory a name
    may carry (:data:`EXAMPLES_DIR`).
    """
    candidate = (name or "").strip()
    prefix = ""
    if candidate.startswith(f"{EXAMPLES_DIR}/"):
        prefix = f"{EXAMPLES_DIR}/"
        candidate = candidate[len(prefix) :]
    if candidate.endswith(NOTEBOOK_SUFFIX):
        candidate = candidate[: -len(NOTEBOOK_SUFFIX)]
    if not _SAFE_NAME.match(candidate):
        raise NotebookError(
            "bad-name",
            f"{name!r} is not a usable notebook name. Use letters, digits, spaces, "
            f"'.', '_' or '-', and no directory separators.",
        )
    return prefix + candidate + NOTEBOOK_SUFFIX

notebook_path

notebook_path(project_root: str | Path, name: str) -> Path

Where a notebook of this name lives. Never outside the directory.

Source code in tit/server/notebooks.py
def notebook_path(project_root: str | Path, name: str) -> Path:
    """Where a notebook of this name lives. Never outside the directory."""
    return _checked_notebook_entry(project_root, normalise_name(name))

seed_example

seed_example(project_root: str | Path, source: Path = EXAMPLE_SOURCE) -> bool

Copy the packaged example into the project. True when it was written.

Written when absent, and re-written when source has changed since the hash recorded in examples/.seeded and the project's copy is still byte-identical to that recorded hash (i.e. the user never edited it). A copy the user edited, or one seeded before the stamp carried a hash, is left alone; a deleted example stays deleted (see the module docstring).

Source code in tit/server/notebooks.py
def seed_example(project_root: str | Path, source: Path = EXAMPLE_SOURCE) -> bool:
    """Copy the packaged example into the project. True when it was written.

    Written when absent, and re-written when *source* has changed since the
    hash recorded in ``examples/.seeded`` **and** the project's copy is still
    byte-identical to that recorded hash (i.e. the user never edited it). A
    copy the user edited, or one seeded before the stamp carried a hash, is
    left alone; a deleted example stays deleted (see the module docstring).
    """
    path = notebook_path(project_root, EXAMPLE_NAME)
    stamp = _example_stamp(project_root)
    recorded = stamp.read_text(encoding="utf-8").strip() if stamp.is_file() else None
    if recorded == _DELETED:
        return False
    packaged = source.read_bytes()
    digest = hashlib.sha256(packaged).hexdigest()
    if path.exists():
        if recorded is None or recorded == digest:
            return False
        if hashlib.sha256(path.read_bytes()).hexdigest() != recorded:
            return False  # the user edited their copy
    path.parent.mkdir(parents=True, exist_ok=True)
    _atomic_text_write(path, packaged.decode("utf-8"))
    _atomic_text_write(stamp, digest + "\n")
    return True

list_notebooks

list_notebooks(project_root: str | Path) -> list[NotebookEntry]

Every notebook, newest first. Missing dir → empty.

Two levels: the flat directory the user writes into, and examples/. Nothing recurses further, because :func:normalise_name cannot name anything deeper and a listing that shows what cannot be opened is a lie.

Source code in tit/server/notebooks.py
def list_notebooks(project_root: str | Path) -> list[NotebookEntry]:
    """Every notebook, newest first. Missing dir → empty.

    Two levels: the flat directory the user writes into, and ``examples/``.
    Nothing recurses further, because :func:`normalise_name` cannot name
    anything deeper and a listing that shows what cannot be opened is a lie.
    """
    directory = notebooks_dir(project_root)
    if not directory.is_dir():
        return []
    paths = sorted(directory.glob(f"*{NOTEBOOK_SUFFIX}"))
    paths += sorted((directory / EXAMPLES_DIR).glob(f"*{NOTEBOOK_SUFFIX}"))
    entries: list[NotebookEntry] = []
    for path in paths:
        name = (
            path.name if path.parent == directory else f"{path.parent.name}/{path.name}"
        )
        try:
            info = notebook_path(project_root, name).stat()
        except (OSError, NotebookError):  # stale file or outward leaf/parent link
            continue
        entries.append(
            NotebookEntry(
                name=name,
                size=info.st_size,
                modified=info.st_mtime,
                example=name.startswith(f"{EXAMPLES_DIR}/"),
            )
        )
    entries.sort(key=lambda entry: (entry.example, -entry.modified))
    return entries

starter_source

starter_source() -> str

The first cell of a new notebook.

The maintainer's ask was that "the TI-Toolbox environment is automatically loaded". It is — the kernel is SimNIBS Python with tit importable — but a blank first cell would leave the author to discover that. So a new notebook opens holding a cell that demonstrates it and runs green: the path manager, the project's subjects, the SimNIBS version, and the two module imports every scripted workflow starts from.

Every name in here is checked by tests/test_notebook_files.py against the real tit API, because the first version of this cell called pm.project_root, which does not exist — a starter cell that raises on its first run is worse than no starter cell at all.

Source code in tit/server/notebooks.py
def starter_source() -> str:
    """The first cell of a new notebook.

    The maintainer's ask was that "the TI-Toolbox environment is automatically
    loaded". It is — the kernel is SimNIBS Python with ``tit`` importable — but
    a blank first cell would leave the author to discover that. So a new
    notebook opens holding a cell that *demonstrates* it and runs green: the
    path manager, the project's subjects, the SimNIBS version, and the two
    module imports every scripted workflow starts from.

    Every name in here is checked by ``tests/test_notebook_files.py`` against
    the real ``tit`` API, because the first version of this cell called
    ``pm.project_root``, which does not exist — a starter cell that raises on
    its first run is worse than no starter cell at all.
    """
    return (
        "# TI-Toolbox is already on this kernel's path — this cell proves it,\n"
        "# and it is the block every scripted workflow starts from.\n"
        "import simnibs\n"
        "\n"
        "from tit import catalog, get_path_manager\n"
        "from tit.analyzer import Analyzer\n"
        "from tit.sim import SimulationConfig\n"
        "\n"
        "pm = get_path_manager()\n"
        "subjects = catalog.subject_ids(pm)\n"
        "\n"
        "print('project ', pm.project_dir)\n"
        "print('simnibs ', simnibs.__version__)\n"
        "print('subjects', subjects)\n"
        "for sid in subjects:\n"
        "    print(f'  {sid}: {pm.list_simulations(sid)}')\n"
    )

new_notebook

new_notebook(project_root: str | Path | None = None) -> dict[str, Any]

An empty nbformat v4 notebook with the starter cell already in it.

Source code in tit/server/notebooks.py
def new_notebook(project_root: str | Path | None = None) -> dict[str, Any]:
    """An empty nbformat v4 notebook with the starter cell already in it."""
    import nbformat

    notebook = nbformat.v4.new_notebook()
    notebook["metadata"] = _METADATA
    notebook["cells"] = [
        nbformat.v4.new_markdown_cell(
            "# New TI-Toolbox notebook\n\n"
            "This kernel is the container's **SimNIBS Python**, so `tit`, `simnibs`, `numpy`, "
            "`nibabel`, `pandas` and `matplotlib` are all importable with nothing to install.\n\n"
            "Run the cell below with **⇧↵**. For a worked example — a real field summarised, "
            "plotted and tabulated — open `examples/example_workflow.ipynb`."
        ),
        nbformat.v4.new_code_cell(starter_source()),
    ]
    return dict(notebook)

read_notebook

read_notebook(project_root: str | Path, name: str) -> dict[str, Any]

Read one notebook as plain JSON-able nbformat v4.

Source code in tit/server/notebooks.py
def read_notebook(project_root: str | Path, name: str) -> dict[str, Any]:
    """Read one notebook as plain JSON-able nbformat v4."""
    import nbformat

    path = notebook_path(project_root, name)
    if not path.is_file():
        raise NotebookError("not-found", f"No notebook named {name!r}.")
    try:
        notebook = nbformat.read(str(path), as_version=4)
    except Exception as error:
        raise NotebookError(
            "bad-notebook", f"{path.name} is not a readable notebook: {error}"
        ) from error
    return dict(notebook)

write_notebook

write_notebook(project_root: str | Path, name: str, content: dict[str, Any]) -> Path

Write one notebook, validating it first.

Validation is not ceremony: what arrives here came from a browser, and a notebook that fails nbformat.validate is one every other tool in the project — Jupyter, nbconvert, git's diff driver — will then refuse to open. Better to reject the save than to write the file that breaks them.

Source code in tit/server/notebooks.py
def write_notebook(
    project_root: str | Path, name: str, content: dict[str, Any]
) -> Path:
    """Write one notebook, validating it first.

    Validation is not ceremony: what arrives here came from a browser, and a
    notebook that fails ``nbformat.validate`` is one every other tool in the
    project — Jupyter, nbconvert, git's diff driver — will then refuse to open.
    Better to reject the save than to write the file that breaks them.
    """
    import nbformat

    path = notebook_path(project_root, name)
    # `examples/` is a real directory the write may be the first to need.
    path.parent.mkdir(parents=True, exist_ok=True)
    try:
        notebook = nbformat.from_dict(content)
        nbformat.validate(notebook)
    except Exception as error:
        raise NotebookError(
            "invalid", f"That is not a valid notebook: {error}"
        ) from error
    # Match nbformat.write's trailing newline without opening an untrusted temp path.
    content = nbformat.writes(notebook, version=4)
    if not content.endswith("\n"):
        content += "\n"
    _atomic_text_write(path, content)
    return path