Metadata-Version: 2.5
Name: ipynb-scrubber
Version: 0.5.0
Summary: Generate exercise versions of Jupyter notebooks
Project-URL: Repository, https://github.com/jkeifer/ipynb-scrubber
Project-URL: Issues, https://github.com/jkeifer/ipynb-scrubber/issues
Author-email: Jarrett Keifer <jkeifer0@gmail.com>
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: education,exercise,ipynb,jupyter,notebook,teaching
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Framework :: Jupyter
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Education
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Education
Classifier: Topic :: Software Development :: Pre-processors
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: pyyaml>=6.0.3
Description-Content-Type: text/markdown

# 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](#python-api))

## Installation

Install with a python package manager like `pip` or `uv`:

```bash
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):

```bash
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](#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:

```text
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:

```text
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:

```text
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:

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

Using custom tags:

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

Using custom placeholder text:

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

### Project-Wide: `scrub-project`

Process multiple notebooks using a configuration file:

```bash
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)
1. `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:

```toml
# 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:

```text
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]`:

```toml
# 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):

```bash
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:

```python
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:

```python
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:

```python
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:

```python
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:

```python
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:

```python
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:

```text
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:

```text
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

```python
#| 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

```markdown
<!-- 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:

```python
# 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
`#`:

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

```python
#| 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:

```python
#| scrub-clear: |
#|   def add(a, b):
#|       # TODO: your code here
#|       pass
def add(a, b):
    return a + b
```

```markdown
<!-- 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:

```python
#| 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:

```text
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:

```python
#| 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:

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

```python
#| 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:

```text
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:

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

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

```python
#| 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:

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

```toml
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:

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

becomes

```python
#| 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:

```python
#| scrub-note: note-id
```

With custom replacement text:

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

With replacement text spanning several lines:

```python
#| 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:

```python
# (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:

```text
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:

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

saves only its body:

```python
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:

```markdown
# 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:**

```bash
# 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):

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

**Code Cell 2** (Quarto option with custom text):

```python
#| 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):

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

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

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

```python
# 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):

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

**Code Cell 2** (cleared with custom text):

```python
# TODO: Write your add function here
```

**Markdown Cell 3** (cleared with custom text):

```markdown
**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.
