Skip to main content

ipynb-scrubber

Generate exercise versions of Jupyter notebooks by clearing solution cells and removing instructor-only content.

[!NOTE] This is a project made to satisfy a need on some personal projects. The behaivor has been tested to work for these projects but will not be supported for other uses.

Issues will be reviewed if opened, and any legitimate bugs will be fixed, but new features or ideas will likely be rejected unless accompanied by a working pull request with comprehensive tests.

Thanks for understanding.

Features

  • Clear solution cells: Replace cell contents with placeholder text while preserving structure
  • Save notes: Collect code cell contents below the option header before clearing and save to a separate Markdown file for instructor reference with bidirectional linking
  • Custom replacement text: Use cell-specific text instead of default placeholder
  • Multi-line replacement content: Write replacement text spanning several lines with a YAML block scalar
  • All cell types supported: Works with code, markdown, and raw cells
  • Remove cells entirely: Omit instructor-only cells from the output
  • Multiple syntax options: Use cell tags or cell-type-appropriate comment syntax
  • Standard header syntax: Code cell options are Quarto #| headers, parsed as the YAML they are
  • Preserve structure: Maintain notebook structure and metadata, and carry through any fields this tool does not interpret
  • Keep other cell options: A code cell's #| header is shared, so a scrubbed cell keeps the directives that are not this tool's, such as Quarto's #| echo: false
  • Clear all outputs: Every cell comes out with no outputs and no execution count, for a clean slate
  • Project-wide processing: Process multiple notebooks with a single command using a TOML config file
  • Never a partial result: Outputs are moved into place only once written in full, and a failing notebook cancels the whole run rather than leaving a half-finished tree
  • Flexible CLI: Unix-style stdin/stdout for single files, or config-based batch processing for projects
  • Python API: Drive the same scrubbing from code, on bytes or on a notebook you have already parsed — with jupytext or nbformat, for instance (see Python API)

Installation

Install with a python package manager like pip or uv:

pip install ipynb-scrubber

Usage

The tool provides two commands for different workflows:

Single Notebook: scrub-notebook

Process a single notebook via stdin/stdout (Unix-style):

ipynb-scrubber scrub-notebook < input.ipynb > output.ipynb

Options

  • --omit-tag TAG: Tag marking cells to omit entirely (default: scrub-omit)
  • --note-tag TAG: Option name marking cells to save to notes (default: scrub-note)
  • --note-reference TEXT: Marker pointing a noted cell at its note, with {id} replaced by the note id (default: # (See notes: {id}))
  • --clear-tag TAG: Tag marking cells to clear (default: scrub-clear)
  • --clear-text TEXT: Replacement text for cleared cells where unspecified (default: # TODO: Implement this)
  • --clear-text-markdown TEXT: Replacement text for cleared markdown cells where unspecified (default: *TODO: Implement this*)
  • --clear-text-raw TEXT: Replacement text for cleared raw cells where unspecified (default: TODO: Implement this)
  • --notes-file PATH: Path to write the notes file, required if any cell carries the note tag (see Notes Files)

A placeholder has to read as the kind of cell it lands in, which is why each cell type gets its own default. The code default is a comment, and a comment dropped into a markdown cell renders as a heading rather than as the note to the student it is meant to be, while a raw cell is passed through verbatim to the output format, where a # marks nothing at all. Code cells use --clear-text, markdown cells --clear-text-markdown, and raw cells --clear-text-raw, whose default carries no markup.

Each of the three tag names must start with a letter and contain only letters, digits, hyphens and underscores. A name is written as a YAML key in a cell's option header as well as a Jupyter metadata tag, so it has to survive that round trip as itself — an empty name, one containing whitespace, or one leading with punctuation is rejected:

clear-tag must start with a letter and contain only letters, digits, hyphens
and underscores, but got 'my tag'

A name YAML reads back as something other than text is rejected for the same reason, even though the pattern allows it. yes, no, on, off, true, false and null — in any capitalisation YAML accepts — are a boolean or nothing at all when they appear as a key, so a cell marked with one would arrive under a key no lookup by name finds and would ship unscrubbed:

omit-tag must be a name YAML reads back as text, but got 'no', which YAML
resolves to another type. Words like yes, no, on, off, true, false and null
are not names

The three names must also differ from one another. Pointing two of them at the same string would make a marked cell ambiguous, so it is rejected:

omit-tag, note-tag, clear-tag must all be distinct, but got
omit-tag='x', note-tag='scrub-note', clear-tag='x'

Examples

Using default settings:

ipynb-scrubber scrub-notebook < lecture.ipynb > exercise.ipynb

Using custom tags:

ipynb-scrubber scrub-notebook \
    --clear-tag solution \
    --omit-tag private \
    < lecture.ipynb > exercise.ipynb

Using custom placeholder text:

ipynb-scrubber scrub-notebook \
    --clear-text "# YOUR CODE HERE" \
    < lecture.ipynb > exercise.ipynb

Project-Wide: scrub-project

Process multiple notebooks using a configuration file:

ipynb-scrubber scrub-project

The command searches for configuration in the following order, starting from the current directory and moving upward:

  1. .ipynb-scrubber.toml (standalone config file)
  2. pyproject.toml with [tool.ipynb-scrubber] section

This means you can run the command from any subdirectory of your project. A relative input, output or notes-file is resolved against the directory holding the config file, not the directory you ran from, so an entry names the same file wherever the command was started. An absolute path is used as written.

A pyproject.toml encountered during the search that cannot be read or parsed as TOML stops the search with an error, rather than being skipped. Since the file cannot be parsed, there is no way to know whether it would have contained a [tool.ipynb-scrubber] section, so neither "keep searching" nor "no config found" would be a trustworthy result. A readable pyproject.toml with no [tool.ipynb-scrubber] section is unaffected and is skipped as before.

Configuration File Formats

Option 1: Standalone .ipynb-scrubber.toml

Create a .ipynb-scrubber.toml file with global options and file entries:

# Global options (optional - these are defaults)
[options]
clear-tag = "scrub-clear"
clear-text = "# TODO: Implement this"
clear-text-markdown = "*TODO: Implement this*"
clear-text-raw = "TODO: Implement this"
omit-tag = "scrub-omit"
note-reference = "# (See notes: {id})"
note-tag = "scrub-note"

# File entries (required - at least one)
[[files]]
input = "lectures/lesson1.ipynb"
output = "exercises/lesson1.ipynb"

[[files]]
input = "lectures/lesson2.ipynb"
output = "exercises/lesson2.ipynb"
clear-text = "# YOUR CODE HERE"  # Override global option
clear-text-markdown = "**YOUR ANSWER HERE**"

[[files]]
input = "lectures/lesson3.ipynb"
output = "exercises/lesson3.ipynb"
clear-tag = "solution"  # Custom tag for this file
omit-tag = "instructor"

Each file entry supports:

  • input (required): Path to source notebook
  • output (required): Path where scrubbed notebook will be written
  • clear-tag (optional): Override global clear tag
  • clear-text (optional): Override global clear text
  • clear-text-markdown (optional): Override global markdown clear text
  • clear-text-raw (optional): Override global raw clear text
  • omit-tag (optional): Override global omit tag
  • note-reference (optional): Override global note reference marker
  • note-tag (optional): Override global note tag
  • notes-file (optional): Path to write the notes file for this notebook

Overrides are presence-based, not truthiness-based: a file entry that sets clear-text = "" gets an empty string for that file rather than falling back to the global default. notes-file is the one exception: an empty string is not a path anything can be written to, and presence leaves nowhere for it to mean "no notes file", so notes-file = "" is an error rather than a silent omission. Leave the key out entirely to have no notes file.

An entry's input, output and notes-file must all name different paths, and no two entries may collide either: two entries cannot write the same output or the same notes-file, and one entry cannot write to a path another entry reads as its input. Scrubbing derives an exercise copy and leaves the original alone, so in-place scrubbing is not supported — output equal to input would replace the source notebook with its own scrubbed copy and destroy the solutions in it. Any of these is a config error that fails the run before a single file is written.

Paths are compared by the file they name rather than by how they are spelled, so ./lesson.ipynb, notebooks/../lesson.ipynb, a symlink pointing at lesson.ipynb, and — on a case-insensitive filesystem — Lesson.ipynb are all recognised as the same file. Where a path names a file that does not exist yet the filesystem has nothing to be asked, and the comparison falls back to the resolved spelling; that still sees through .. and a symlinked parent, but two not-yet-created paths differing only in case are not caught.

Unknown keys anywhere in the config — the top level, [options], or a [[files]] entry — are rejected, and the error names the invalid key and lists the valid ones, so a misspelled clear-tagg fails the run instead of silently leaving solution cells unscrubbed.

Values are type-checked too. TOML can put anything at all under a key, so a value of the wrong type is reported against the key that holds it rather than found later by whatever chokes on it:

clear-text must be str, but got int: 42

Every option is checked, in [options] and in a [[files]] entry alike, as are an entry's input, output and notes-file: all three name a path, and a path is something TOML can only spell as a string.

clear-tag, omit-tag and note-tag must each be a usable name — starting with a letter and containing only letters, digits, hyphens and underscores — and must differ from one another. Both rules are checked in [options] and again after a [[files]] entry's overrides are applied, so an entry that overrides one tag onto the value of another inherited from [options] is rejected on that basis.

Option 2: Using pyproject.toml

Add configuration to your existing pyproject.toml under [tool.ipynb-scrubber]:

# Global options (optional - these are defaults)
[tool.ipynb-scrubber.options]
clear-tag = "scrub-clear"
clear-text = "# TODO: Implement this"
clear-text-markdown = "*TODO: Implement this*"
clear-text-raw = "TODO: Implement this"
omit-tag = "scrub-omit"
note-reference = "# (See notes: {id})"
note-tag = "scrub-note"

# File entries (required - at least one)
[[tool.ipynb-scrubber.files]]
input = "lectures/lesson1.ipynb"
output = "exercises/lesson1.ipynb"

[[tool.ipynb-scrubber.files]]
input = "lectures/lesson2.ipynb"
output = "exercises/lesson2.ipynb"
clear-text = "# YOUR CODE HERE"

This is convenient if you're already using pyproject.toml for your Python project. The tool will automatically find and use this configuration.

Custom Config File

Specify a different config file location (bypasses automatic discovery):

ipynb-scrubber scrub-project --config-file path/to/config.toml

Python API

Everything the commands do is available as a library. The public surface is exported from the package root:

from ipynb_scrubber import (
    Cell,
    FileEntry,
    InvalidNotebookError,
    Notebook,
    NotebookScrubResult,
    ProcessingError,
    ProjectConfig,
    ScrubberError,
    ScrubbingOptions,
    ScrubResult,
    process_notebook,
    scrub,
    scrub_files,
    scrub_parsed,
)

Scrubbing a notebook in memory

process_notebook takes a parsed notebook and returns a new one alongside the notes it collected. It leaves its argument alone and either returns a complete exercise notebook or raises, so a failure part way through a notebook cannot hand back something half-scrubbed:

import json

from ipynb_scrubber import ScrubbingOptions, process_notebook

with open('lecture.ipynb') as f:
    notebook = json.load(f)

exercise, notes = process_notebook(notebook, ScrubbingOptions())

notes maps each note id to the original source of the cell it came from, below that cell's option header. ScrubbingOptions carries the same settings as the CLI flags, so ScrubbingOptions(clear_text='# YOUR CODE HERE') mirrors --clear-text '# YOUR CODE HERE'.

Instances are immutable: assigning to a field raises. Every rule the options enforce — the value types, and the tag names being usable, readable back as text and distinct from each other — is checked when an instance is built, and a settable field would be a way around all of them. Derive a modified copy instead, which is checked the same way:

import dataclasses

from ipynb_scrubber import ScrubbingOptions

opts = ScrubbingOptions()

opts.merged_with({'clear-text': '# YOUR CODE HERE'})  # by config key
dataclasses.replace(opts, clear_text='# YOUR CODE HERE')  # by field name

Using with jupytext

scrub_parsed is the whole pipeline bar the parsing and the serializing. It takes a notebook you have already parsed and hands it back in the class it arrived as, so the library that read it is the library that writes it:

import jupytext

from pathlib import Path

from ipynb_scrubber import ScrubbingOptions, scrub_parsed

notebook = jupytext.reads(Path('lesson.py').read_text(), fmt='py:percent')
result = scrub_parsed(notebook, ScrubbingOptions())

Path('exercise.py').write_text(jupytext.writes(result.notebook, fmt='py:percent'))
if result.notes_text is not None:
    Path('notes.md').write_text(result.notes_text)

result is a NotebookScrubResult, carrying the same notes_text and note_count a ScrubResult does, with the notebook as an object rather than as text.

Neither jupytext nor nbformat is a dependency of this package. Nothing here is specific to either: scrub_parsed works just as well on a notebook from nbformat.read(), from json.load(), or built by hand. It works because every mapping the pipeline rebuilds is rebuilt in the class it arrived as, and a plain dict in gives a plain dict out.

Use process_notebook instead when you want the notes as a mapping of note id to source rather than as a rendered document.

Scrubbing a notebook end to end

scrub is the whole pipeline both commands run — parse, process, render — without touching a single file. It takes the raw bytes of a notebook and returns a ScrubResult holding the text of every output:

from pathlib import Path

from ipynb_scrubber import ScrubbingOptions, scrub

result = scrub(Path('lecture.ipynb').read_bytes(), ScrubbingOptions())

result.notebook_text  # the exercise notebook, serialized as Jupyter writes it
result.notes_text  # the rendered notes, or None if no cell was noted
result.note_count  # how many cells were noted

Bytes rather than text, because a notebook's encoding is a property of the notebook rather than of the locale the program happens to run in. Where the results go is yours to decide; if you want them written for you, use scrub_files.

Running a config

ProjectConfig loads the same TOML the scrub-project command reads, and scrub_files runs a whole batch, reading each input and writing each exercise notebook and notes file:

from ipynb_scrubber import ProjectConfig, scrub_files

config = ProjectConfig.discover()  # or ProjectConfig.from_file(path)

scrub_files(config.files)

scrub_files is all-or-nothing across the batch: every output is staged first and committed only once all of them have succeeded. It is the only entry point for writing files, and passing it a one-element list is how you scrub a single notebook — calling it once per entry in a loop is not equivalent, because a failure on the fourth notebook would leave the first three committed.

Each FileEntry carries its own fully resolved ScrubbingOptions, with any per-entry overrides already merged over the global ones, so entry.options is what that notebook will actually be scrubbed with.

A FileEntry built by hand is checked exactly as one read from a config file is: constructing one whose input, output and notes_file are not all distinct raises ScrubberError rather than overwriting the source notebook. The checks that need the whole batch — two entries writing one path, or an entry writing over another's input — belong to ProjectConfig, so a list of entries assembled by hand and passed straight to scrub_files does not get them.

Errors

Every failure caused by input or configuration raises ScrubberError, or one of the two subclasses it exports: InvalidNotebookError when a notebook is not shaped like a notebook, and ProcessingError when a cell's option header cannot be honored. Catching ScrubberError catches all of them. Anything else escaping these functions is a defect in this tool rather than a problem with the input.

Marking Cells

There are two ways to mark cells for processing:

1. Cell Tags (All Cell Types)

Add tags to cells using Jupyter's tag interface. This works for all cell types (code, markdown, raw):

  • Add scrub-clear tag to solution cells that should be cleared
  • Add scrub-omit tag to cells that should be removed entirely

A tag is an instruction to this tool, so it does not survive into the exercise notebook: the tag names are removed from metadata.tags on the way out, the same way the source-header spelling is removed from a cell's source. Tags this tool does not define stay, and a cell left with no tags at all loses the empty tags key rather than carrying one — an empty list where a tag used to be would point at the scrubbed cell just as plainly as the tag did.

Note: The scrub-note option requires source-based syntax (see below) and is valid only in code cells; using it elsewhere is an error.

2. Source-Based Options (Code & Markdown)

Use cell-type-appropriate syntax for more control, including custom replacement text. The option header must be the first non-blank content in the cell's source — a #| scrub-clear: (or <!-- scrub-clear: -->) preceded by any other line is not recognized as an option and is silently left as ordinary source.

The header is YAML, the same language Quarto's #| header is written in. Every option is a name: value entry, the colon is required even when there is no value, and values follow YAML's rules for quoting, typing and multi-line text. Names the tool does not define, including Quarto's own options, are ignored.

An option name written without its colon is an error, because a bare name is not an option at all. It is a plain YAML scalar, and a plain scalar swallows the lines below it, so a #| scrub-omit sitting above a note to self folds into the one string scrub-omit note to self. Rather than read that as a comment about omitting, the tool says what is missing:

Cell 1: Option 'scrub-omit' is missing its colon. The cell option header is
YAML and an option is a 'name: value' entry, so write 'scrub-omit:'

The header is shared with whatever else writes in the same comments, so ownership is settled one entry at a time: only an entry whose key is a scrubber option is the tool's to read, or to complain about. A neighbour's repeated fig-cap:, and a #| 12: hello whose name is not text, are left alone rather than failing the run — including in a header that carries a scrubber option too.

That reprieve is for a neighbour the header still parses with. A neighbour that stops the header being YAML at all is a different matter. A #|----- divider on its own is fine — it is a YAML scalar, and a header holding no mapping and naming no scrubber option yields no options rather than an error. Put it above a #| scrub-clear:, though, and the two together are not YAML, so the run fails: see "A header that is not well-formed YAML" below.

Ownership is read off the parsed header, never guessed from the raw text. Only a key names an option, so neither a scrubber name buried in a longer key (my-scrub-omit-helper:) nor one appearing in somebody else's value (fig-cap: see scrub-note docs) hands the tool a header it does not own.

A header that is not well-formed YAML is reported whether or not it names a scrubber option. There is no parsed header to read ownership off, guessing from the raw text would claim headers that merely look like this tool's, and Quarto reads the same #| block as YAML, so text that malformed is broken for whoever else writes there too. A #| fig-cap: A: B therefore fails the run:

Cell 1: Invalid cell option header: line 1 has a second ':' in its value. The
header is YAML, so a value containing ':' or '#' has to be quoted (name:
"Figure 1: a plot")

An unquoted : in a value is much the likeliest way a header stops being YAML, because a caption like fig-cap: Figure 1: Temperature is the natural thing to write. Quoting it is what Quarto asks for too, so the fix serves both readers.

That rule applies to code cells, where #| is a convention this tool shares with Quarto and malformed text is broken for both readers. A markdown cell is different: an HTML comment is ordinary markdown, not a header anyone agreed to share, and a notebook is full of comments left by formatters and site generators. So a markdown cell's leading comments are read as a header only if one of the scrubber option names appears in the cell at all; otherwise they are left exactly as written. A <!-- @format --> or a <!-- {% raw %} --> passes through untouched instead of failing the run.

Naming an option puts the whole comment run back in play, siblings included — so a neighbouring comment that is not YAML will still fail a cell that also carries a <!-- scrub-omit: -->. The test is deliberately loose in the safe direction: it can only cause the tool to look at a header it turns out not to own, never to pass over one it does. An option spelled anything other than its configured name does nothing regardless, so there is no marking this can miss.

Code Cells - Quarto Options

#| scrub-clear:
def secret_solution():
    return 42


# Or with custom replacement text:
#| scrub-clear: "# WRITE YOUR SOLUTION HERE"
def another_solution():
    return 'hidden'


# To save to notes and clear (requires an id):
#| scrub-note: exercise-1
def solution_with_notes():
    # This solution will be saved to the notes file
    # and then cleared from the student version
    return 'answer'


# With custom replacement text:
#| scrub-note:
#|   id: exercise-2
#|   text: "# YOUR SOLUTION HERE"
def another_noted_solution():
    return 'more answers'


# To omit entirely:
#| scrub-omit:
# This cell will be removed
print('Instructor only!')

Markdown Cells - HTML Comments

<!-- scrub-clear: -->
## Answer

The solution is 42 because...

<!-- scrub-clear: "**Write your answer here**" -->
## Another Question

This answer will be replaced, with custom text.

<!-- scrub-omit: -->
## Instructor Notes

These notes are only for the instructor.

Note: The scrub-note option is valid only in code cells. Using it in a markdown cell is an error and fails the run — it is never silently ignored, so a note tag on a markdown answer cell can't accidentally ship the answer to students.

Raw Cells - Tags Only

Raw cells only support metadata tags to avoid format conflicts:

# Cell metadata: {"tags": ["scrub-clear"]}
$$\int_0^1 x^2 dx = \frac{1}{3}$$

# Cell metadata: {"tags": ["scrub-omit"]}
% This LaTeX comment will be omitted entirely

Custom Replacement Text

When using source-based options, you can specify custom text to replace the cleared content:

  • #| scrub-clear: Your custom text (code cells)
  • <!-- scrub-clear: Your custom text --> (markdown cells)
  • Empty text: #| scrub-clear: "" (results in empty cell)

An option written with no value at all — #| scrub-clear: in a code cell, <!-- scrub-clear: --> in a markdown cell, or the scrub-clear metadata tag in any cell — uses the configured default for the kind of cell it is in: --clear-text in a code cell, --clear-text-markdown in a markdown cell, --clear-text-raw in a raw cell. A raw cell has no comment syntax to hide a header in, so the metadata tag is the only spelling that reaches one.

Replacement text containing # must be quoted or written as a block scalar. In YAML an unquoted # opens a comment that runs to the end of the line, which would take the replacement text with it. Writing one is an error naming the option, so text is never lost in silence. Both spellings keep the #:

#| scrub-clear: "# TODO: your code here"
#| scrub-clear: |
#|   # TODO: your code here

Only the options that carry text are guarded this way. An option carrying no value has nothing for a comment to cut short, so #| scrub-omit: # some comment is fine. A comment beside a name the tool does not define is somebody else's to read and is left alone too.

Quoting is what other awkward text needs too: text starting with *, &, !, |, >, [, {, %, @ or `, and text containing : . Quoting is always safe, so quote when in doubt.

Values keep the type YAML gives them, and a value that is not text is an error rather than a surprise. #| scrub-clear: no is the boolean false, so it fails the run instead of clearing the cell to False; write #| scrub-clear: "no" to mean the word.

Multi-line Replacement Text

Use a YAML block scalar for replacement text spanning several lines. The | opens the block, content is indented relative to the option, and that indentation is stripped:

#| scrub-clear: |
#|   def add(a, b):
#|       # TODO: your code here
#|       pass
def add(a, b):
    return a + b
<!-- scrub-clear: |
  **Write your answer here**

  Show your work.
-->
## Solution

A block scalar is verbatim: no comment stripping, no escapes, no quoting. That makes it the right place for content full of backslashes or #, such as regexes or LaTeX.

Indent the content more deeply than the option line. Content at the option's own indentation is a sibling option instead of block content:

#| scrub-clear: |
#| scrub-omit:          <- error, not a silent cell deletion

A cell's source header may carry at most one scrubber option, so that mistake fails the run rather than quietly deleting the cell. When one of the options present opened a block, the message names it, since that is the line the content belongs under:

Cell 1: only one scrubber option per cell, but found scrub-clear, scrub-omit.
If one of these was meant as content of 'scrub-clear', indent it more deeply
than that option's line

Options that are not scrubber options, such as Quarto's own, remain valid siblings:

#| scrub-note: ex-1
#| echo: false

Indent with spaces. YAML forbids a tab in indentation, and a header containing one is reported as such.

In a code cell, a blank line inside a block scalar may keep its #| marker or drop it. Both of these yield a, a blank line, and b, because a blank line belongs to the header whenever another #| line follows it:

#| scrub-clear: |
#|   a
#|
#|   b
#| scrub-clear: |
#|   a

#|   b

In a markdown cell the comment stays open across the block: the | is the last thing on its line, the content follows, and a line containing only --> closes the header. Blank lines up to it are kept verbatim, as in the example above. A comment that is never closed is an error.

Repeating a name within one cell's header is an error, as is reusing the same scrub-note id anywhere in a notebook. Either would otherwise resolve by keeping the last one, which in the note case discards an instructor solution. The check descends into an option written as a mapping, because everything under such a name belongs to the option too, and the repeat is named with the path to it:

Cell 1: Duplicate option 'scrub-note.id' in cell option header

Combining scrubber options in one header is an error rather than a precedence puzzle. Metadata tags are not subject to that rule. A tag carries presence and nothing else, which is exactly what an option written with no value carries, so tags and header options merge into one set and a single precedence order — omit, then note, then clear — covers both. The header wins where both name the same option, so a cell tagged both scrub-omit and scrub-note is still simply omitted, and a scrub-omit tag still wins over a #| scrub-note: in source. A tag does not paper over a bad header, though: a cell tagged scrub-omit whose header says #| scrub-omit: something fails, because scrub-omit takes no value.

Quoting and Escapes

A double-quoted value expands YAML's escapes, so \n and \t fit on a single line:

#| scrub-clear: "line one\nline two"

A single-quoted value is literal, which suits a regex:

#| scrub-clear: 're.match(r"\d+", s)'

A block scalar is literal too, and handles several lines at once.

--clear-text and TOML clear-text use their own native mechanisms:

ipynb-scrubber scrub-notebook \
    --clear-text $'def add(a, b):\n    # TODO\n    pass' \
    < lecture.ipynb > exercise.ipynb
clear-text = """
def add(a, b):
    # TODO
    pass"""

A \n in a TOML literal string (single quotes) stays literal.

Other Options in the Header

In a code cell, only this tool's own options are removed. The #| header is shared, so the directives belonging to whoever else writes there configure the cell that remains rather than the content that was replaced. They ride into the output above the replacement text, in the order they were written:

#| echo: false
#| scrub-clear: "# TODO: your code here"
#| fig-cap: A caption
def add(a, b):
    return a + b

becomes

#| echo: false
#| fig-cap: A caption
# TODO: your code here

An option owns every line from its own key down to the next key, which is what keeps a block scalar's content with the option that opened it: the lines under a #| scrub-clear: | go when it does, and the lines under a neighbour's block scalar stay when it stays.

Options written below the scrubber option are kept as readily as those above it; everything kept sits above the replacement text. A cell with no other options in its header gains no header at all, and its output is exactly the replacement text. The same applies to scrub-note, whose reference comment is written under the kept lines, and to a scrub-clear metadata tag on a cell whose header holds nothing but somebody else's options. scrub-omit is unaffected either way, since the whole cell goes.

This is code cells only. A markdown cell's header is still replaced whole: the <!-- and --> delimiters are not options, so they cannot be rebuilt from the lines that survive, and nothing but this tool writes options in a markdown cell's comments anyway. A <!-- scrub-clear: TODO --> yields just TODO.

Notes Files

Code cells only - A code cell carrying a #| scrub-note: <id> option has its content below the header saved to a separate Markdown file before being cleared from the student version. This creates bidirectional linking between the exercise and solutions.

Markdown notes are not supported. The reference text inserted into the cleared cell is one marker for the whole run — --note-reference — so supporting them requires a per-cell-type reference format rather than a single one.

There is no scrub-note cell tag. Unlike scrub-clear and scrub-omit, the option is source-only: a note needs an id, and a Jupyter metadata tag has nowhere to put one. A cell tagged scrub-note fails the run rather than being ignored, because ignoring it would ship the solution to students. (A cell tagged both scrub-omit and scrub-note is simply omitted.)

Required format: the option takes either the note id on its own, or a mapping carrying the id and the text to leave in the cleared cell.

Just the id, which leaves the configured clear text behind:

#| scrub-note: note-id

With custom replacement text:

#| scrub-note:
#|   id: note-id
#|   text: "# YOUR CODE HERE"

With replacement text spanning several lines:

#| scrub-note:
#|   id: note-id
#|   text: |
#|     multi-line replacement
#|     from the block below

id is required and must be a non-empty string. text is optional and defaults to the configured clear text. Any other key is an error, as is a value that is neither an id nor a mapping.

The id is required. A scrub-note with no id, an empty id, or a mapping that omits id, is an error rather than a silent skip.

The note-id should be a human-readable identifier (e.g., exercise-1, question-2a). When the cell is cleared, a reference comment is automatically added:

# (See notes: exercise-1)
# TODO: Implement this

This creates a clear link from the exercise notebook to the notes file.

The marker is --note-reference (config key note-reference), in which {id} — its only placeholder — is replaced by the note id; every other brace is left as written. The default is a Python comment, so a kernel commenting with // or -- needs its own, for example --note-reference '// (See notes: {id})'.

Note ids must be unique within a notebook. Reusing one is an error that names both cells involved, for example:

Cell 2: Duplicate note id 'ex-1'; already used by cell 0. Note ids must be
unique within a notebook

A note cell requires somewhere to put the note. If a notebook contains note cells, scrub-notebook requires --notes-file and scrub-project requires notes-file on that entry; without one the run fails and nothing is written.

Scrubbing a note cell replaces its body with a note-reference pointer, so producing the exercise notebook without the notes file it points at would leave that reference dangling.

The notes file never outlives its notebook. Both commands write it last: scrub-notebook commits it only once the exercise notebook has reached stdout, and scrub-project commits it as part of the batch. A run that fails to deliver the notebook leaves no notes file behind describing one nobody received.

The note is the cell's content below the header, not the header itself. The header is an instruction to this tool rather than part of the cell, and a scrub-note carrying text holds the very scaffolding the student is meant to fill in, so filing it with the note would put the exercise prompt directly above the answer it is a prompt for. This cell:

#| scrub-note:
#|   id: exercise-1
#|   text: |
#|     def solve():
#|         pass
def solve():
    return 42

saves only its body:

def solve():
    return 42

Note bodies are fenced in the notebook's own language, read from metadata.language_info.name or metadata.kernelspec.language. A notebook that declares neither is fenced as python.

A fence is as long as its body requires. A note whose cell contains backticks — source that prints or documents Markdown — is fenced with a run longer than the longest one inside it. Markdown closes a fenced block at the first run at least as long as the one that opened it, so a fixed three backticks would let such a cell end its own note early and swallow the rest of the notes file as prose.

Notes file format:

The notes file is generated in Markdown format with human-readable IDs:

# Notebook Notes

This file contains the original content of cells marked for note-taking.

## exercise-1

\```python
def secret_solution():
    return 42
\```

## question-2a

\```python
def another_solution():
    return "answer"
\```

---
*Generated by ipynb-scrubber*

Usage examples:

# scrub-notebook with notes
ipynb-scrubber scrub-notebook --notes-file solutions.md < lecture.ipynb > exercise.ipynb

# scrub-project with notes in config
# .ipynb-scrubber.toml:
# [[files]]
# input = "lecture.ipynb"
# output = "exercise.ipynb"
# notes-file = "solutions.md"

Example

Input Notebook

Code Cell 1 (no tags):

# Instructions - this will remain unchanged
print('Exercise: implement the functions below')

Code Cell 2 (Quarto option with custom text):

#| scrub-clear: "# TODO: Write your add function here"
def add(a, b):
    return a + b


result = add(1, 2)
print(f'Result: {result}')

Markdown Cell 3 (HTML comment):

<!-- scrub-clear: "**Write your explanation here**" -->
## Solution Explanation

The add function works by using the + operator...

Code Cell 4 (cell tag - will be omitted):

# Cell has metadata: {"tags": ["scrub-omit"]}
# This cell will be removed entirely
assert add(1, 2) == 3
print('Tests pass!')

Output Notebook

Code Cell 1 (unchanged):

# Instructions - this will remain unchanged
print('Exercise: implement the functions below')

Code Cell 2 (cleared with custom text):

# TODO: Write your add function here

Markdown Cell 3 (cleared with custom text):

**Write your explanation here**

Code Cell 4 (omitted entirely)

Behavior

  • All cell outputs are cleared: Every code cell's outputs is emptied and its execution_count set to null; any other cell type has both keys dropped
  • Tagged cells are processed:
    • Cells with the clear tag have their source code replaced with placeholder text
    • Cells with the omit tag are removed entirely from the output
  • Notebook metadata: An exercise_version flag is added to the notebook metadata
  • Unrecognized fields are carried through: Notebook and cell keys this tool does not interpret are passed to the output untouched
  • Other cell options are carried through: Clearing a code cell removes only the lines its own options occupy, so the rest of the #| header survives above the replacement text. A markdown cell's header is replaced whole
  • No scrubber marking survives: The output carries neither spelling of this tool's options — the header lines are removed from the source and the tag names from metadata.tags — so nothing in an exercise notebook says which cells held the answers. Other tags are left alone
  • A notebook is scrubbed whole or not at all: An error anywhere in a notebook means no output for it, rather than a partially scrubbed result
  • A source notebook is never written to: A config whose paths collide — an entry scrubbing onto its own input, two entries writing one file, an entry writing over another's input — is rejected before anything is read or written
  • Outputs are written atomically: Each file is written beside its target and moved into place once complete, so no output is ever seen half-written. A scrub-project run stages every file first and commits only once all of them have succeeded, so a failing entry cancels the whole batch. A scrub-notebook run stages its notes file and commits it only once the exercise notebook has reached stdout, so notes never outlive the notebook they annotate. Committing several files is several moves rather than one transaction, so an interruption during the commit itself can leave some entries written
  • Error handling: A problem with a notebook, a config or a cell's options is reported as a short message with a non-zero exit status. Anything else is a defect in this tool and surfaces as a traceback

Output Format

Exercise notebooks and notes files are meant to be diffed and kept in version control, so what comes out is pinned rather than left to the machine the tool happened to run on:

  • Always UTF-8: Output is written as UTF-8 whatever the locale says. Input notebooks are read as bytes and the encoding is taken from the JSON itself, so a notebook containing an accent is not a crash on a machine whose locale is not UTF-8
  • Non-ASCII is written literally: Characters are not escaped to \uXXXX, matching what Jupyter's own writer does, so notebooks holding accents or emoji stay readable in a diff
  • A trailing newline: The file ends with one, so anything appending to it is not a spurious diff hunk
  • One-space indentation: The same as Jupyter's, so a scrubbed notebook is diffable against one that has been opened and saved
  • Source shape is preserved: nbformat lets a cell's source be one string or a list of lines, and a rewritten cell is written back in the shape it arrived in. A cell that was a list of lines does not collapse into a single long line while its untouched neighbours stay line-per-line
  • outputs and execution_count are emptied, not removed: A scrubbed code cell gets outputs: [] and execution_count: null. The nbformat schema requires both keys on a code cell, so removing them would produce a notebook that fails validation. Cells of any other type must not carry them at all, so there they are dropped

License

Apache License 2.0

Contributing

Contributions are welcome! Please feel free to submit a Pull Request, but note that comprehensive test coverage and clear justification for why the request should be considered (keeping in mind new features increase the maintenance burden) must be included.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

ipynb_scrubber-0.5.0.tar.gz (150.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

ipynb_scrubber-0.5.0-py3-none-any.whl (56.1 kB view details)

Uploaded Python 3

File details

Details for the file ipynb_scrubber-0.5.0.tar.gz.

File metadata

  • Download URL: ipynb_scrubber-0.5.0.tar.gz
  • Upload date:
  • Size: 150.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for ipynb_scrubber-0.5.0.tar.gz
Algorithm Hash digest
SHA256 1ceac3645375b0f5a6d63dda170980feedb2be066db54b4d7c2641c8ad381436
MD5 fec5694687fceb73456569f72c69607d
BLAKE2b-256 1a285beeb77a2fc0fd152e240895d48dd787c0e5fb4d1bb623f0036570312a1b

See more details on using hashes here.

Provenance

The following attestation bundles were made for ipynb_scrubber-0.5.0.tar.gz:

Publisher: release.yml on jkeifer/ipynb-scrubber

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file ipynb_scrubber-0.5.0-py3-none-any.whl.

File metadata

  • Download URL: ipynb_scrubber-0.5.0-py3-none-any.whl
  • Upload date:
  • Size: 56.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for ipynb_scrubber-0.5.0-py3-none-any.whl
Algorithm Hash digest
SHA256 d5fe5f0bd469e34ba9c69b2ef339de0c4967dfc6e5c75946c3490c256600e4c9
MD5 fc00491d0068be32c6bc224881ced1c1
BLAKE2b-256 3736a309d15dc7d86359b09ebdfe232093b5132198c7a5c879ff45f6417ee0ec

See more details on using hashes here.

Provenance

The following attestation bundles were made for ipynb_scrubber-0.5.0-py3-none-any.whl:

Publisher: release.yml on jkeifer/ipynb-scrubber

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.5.0 This release

2 files

0.4.1

2 files

0.4.0

2 files

0.3.0

2 files

0.2.0

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page