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:
.ipynb-scrubber.toml(standalone config file)pyproject.tomlwith[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 notebookoutput(required): Path where scrubbed notebook will be writtenclear-tag(optional): Override global clear tagclear-text(optional): Override global clear textclear-text-markdown(optional): Override global markdown clear textclear-text-raw(optional): Override global raw clear textomit-tag(optional): Override global omit tagnote-reference(optional): Override global note reference markernote-tag(optional): Override global note tagnotes-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-cleartag to solution cells that should be cleared - Add
scrub-omittag 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
outputsis emptied and itsexecution_countset 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_versionflag 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-projectrun stages every file first and commits only once all of them have succeeded, so a failing entry cancels the whole batch. Ascrub-notebookrun 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
sourcebe 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 outputsandexecution_countare emptied, not removed: A scrubbed code cell getsoutputs: []andexecution_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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1ceac3645375b0f5a6d63dda170980feedb2be066db54b4d7c2641c8ad381436
|
|
| MD5 |
fec5694687fceb73456569f72c69607d
|
|
| BLAKE2b-256 |
1a285beeb77a2fc0fd152e240895d48dd787c0e5fb4d1bb623f0036570312a1b
|
Provenance
The following attestation bundles were made for ipynb_scrubber-0.5.0.tar.gz:
Publisher:
release.yml on jkeifer/ipynb-scrubber
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
ipynb_scrubber-0.5.0.tar.gz -
Subject digest:
1ceac3645375b0f5a6d63dda170980feedb2be066db54b4d7c2641c8ad381436 - Sigstore transparency entry: 2431451050
- Sigstore integration time:
-
Permalink:
jkeifer/ipynb-scrubber@d22e64dff5cb3824ca9f7702e007a5e0a59f1d72 -
Branch / Tag:
refs/tags/v0.5.0 - Owner: https://github.com/jkeifer
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@d22e64dff5cb3824ca9f7702e007a5e0a59f1d72 -
Trigger Event:
release
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d5fe5f0bd469e34ba9c69b2ef339de0c4967dfc6e5c75946c3490c256600e4c9
|
|
| MD5 |
fc00491d0068be32c6bc224881ced1c1
|
|
| BLAKE2b-256 |
3736a309d15dc7d86359b09ebdfe232093b5132198c7a5c879ff45f6417ee0ec
|
Provenance
The following attestation bundles were made for ipynb_scrubber-0.5.0-py3-none-any.whl:
Publisher:
release.yml on jkeifer/ipynb-scrubber
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
ipynb_scrubber-0.5.0-py3-none-any.whl -
Subject digest:
d5fe5f0bd469e34ba9c69b2ef339de0c4967dfc6e5c75946c3490c256600e4c9 - Sigstore transparency entry: 2431451100
- Sigstore integration time:
-
Permalink:
jkeifer/ipynb-scrubber@d22e64dff5cb3824ca9f7702e007a5e0a59f1d72 -
Branch / Tag:
refs/tags/v0.5.0 - Owner: https://github.com/jkeifer
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@d22e64dff5cb3824ca9f7702e007a5e0a59f1d72 -
Trigger Event:
release
-
Statement type: