Metadata-Version: 2.4
Name: sphinx-examples-as-code
Version: 0.12.1
Summary: Sphinx extension for converting docstring examples into downloadable code.
Author-email: The PyVista Developers <info@pyvista.org>
License-Expression: MIT
Project-URL: Homepage, https://github.com/pyvista/sphinx-examples-as-code
Keywords: download,examples,sphinx
Classifier: Development Status :: 4 - Beta
Classifier: Framework :: Sphinx :: Extension
Classifier: Intended Audience :: Science/Research
Classifier: Operating System :: MacOS
Classifier: Operating System :: Microsoft :: Windows
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Scientific/Engineering :: Visualization
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: packaging>=22
Requires-Dist: pyvista>=0.48
Provides-Extra: audit
Requires-Dist: ipykernel; extra == "audit"
Requires-Dist: marimo>=0.14; extra == "audit"
Requires-Dist: nbconvert; extra == "audit"
Provides-Extra: gallery
Requires-Dist: sphinx-gallery<0.23,>=0.22; extra == "gallery"
Provides-Extra: marimo
Requires-Dist: marimo>=0.14; extra == "marimo"
Dynamic: license-file

# sphinx-examples-as-code

A Sphinx extension that turns docstring/page "Examples" sections into downloadable,
runnable Python scripts, Jupyter notebooks and/or [marimo](https://marimo.io) notebooks,
with a download link inserted into the section.

Pages or docstrings without an Examples section are left completely untouched. Adding
`sphinx_examples_as_code` to `conf.py`'s `extensions` is the only on/off switch.

## Installation

```bash
pip install sphinx-examples-as-code
```

Sphinx-Gallery integration is opt-in and needs a compatible sphinx-gallery, which the
`gallery` extra pins:

```bash
pip install 'sphinx-examples-as-code[gallery]'
```

marimo notebook downloads need marimo 0.14 or newer, which the `marimo` extra installs:

```bash
pip install 'sphinx-examples-as-code[marimo]'
```

Add it to your Sphinx `conf.py`:

```python
extensions = [
    ...,
    'sphinx_examples_as_code',
]
```

## Configuration

Everything lives in one dict in `conf.py`, `sphinx_examples_as_code_conf` -- set only
the keys you want to change from their default:

```python
sphinx_examples_as_code_conf = {
    'link_position': 'top',
    'formats': ['python', 'jupyter', 'marimo'],
    'gallery_downloads': False,
    'footer': (
        'Generated by `sphinx-examples-as-code '
        '<https://github.com/pyvista/sphinx-examples-as-code>`_'
    ),
    'link_labels': {
        'python': 'Download Python script',
        'jupyter': 'Download Jupyter notebook',
        'marimo': 'Download marimo notebook',
    },
    'script_metadata': False,
    'dependencies': {},
    'requires_python': None,
}
```

- `link_position`: where the download link(s) land within the Examples section. `'top'`
  (default) or `'bottom'`.
- `formats`: which downloads to generate. A list containing any of `'python'` (a plain
  Python script, `<name>.py`), `'jupyter'` (a Jupyter notebook, `<name>.ipynb`) and
  `'marimo'` (a marimo notebook, `<name>_marimo.py`); defaults to all three. Always
  offered in that order regardless of how the list is written. Without marimo 0.14 or
  newer installed, the default skips the marimo download, and a `formats` that names
  `'marimo'` (in `conf.py` or through `-D`) stops the build with a configuration error. An unknown format raises a configuration error too;
  the old `'py'` and `'ipynb'` names say what replaced them. `<name>` is the documented
  object's full name without its top-level package, e.g. `Plotter_show` for
  `pyvista.Plotter.show`, or the gallery example's file name.
- `link_labels`: the text of the download link(s) themselves, per format. Set only the
  format(s) you want to change; any left unset keep reading their own default shown above.
- `gallery_downloads`: opt-in takeover of
  [sphinx-gallery](https://sphinx-gallery.github.io)'s own per-example downloads.
  `False` (default) leaves sphinx-gallery pages untouched. See
  [Sphinx-Gallery integration](#sphinx-gallery-integration) below.
- `footer`: a string appended to the end of every generated file. Defaults to a one-line
  "generated file" credit linking back to this project; set to `None` to omit it
  entirely. Preceded by a blank line and a `-`-only divider line, which also renders as a
  real horizontal rule in notebooks; the footer always gets its own dedicated cell there.
  Parsed as RST: a hyperlink written as `` `text <url>`_ `` becomes a real clickable
  Markdown link in notebooks, and renders inline as `text url` in Python scripts. Plain
  text with no markup at all becomes one comment line per line of text, and
  blank-line-separated paragraphs stay separated.
- `script_metadata`: `True` adds each download's dependencies and a one-line run
  command. See [Dependencies and run commands](#dependencies-and-run-commands) below.
  `False` (default) adds neither.
- `dependencies`: extra PEP 508 requirements per format, added to the detected ones, e.g.
  `{'jupyter': ['pyvista[jupyter]']}`. An entry naming a detected project replaces it,
  so `{'python': ['pyvista>=0.46']}` sets its version. An entry without a version keeps
  the automatic pin described below. Only used with `script_metadata` on.
- `requires_python`: a `requires-python` specifier for the dependency block, e.g.
  `'>=3.10'`. `None` (default) derives one from the dependencies instead; see
  [Dependencies and run commands](#dependencies-and-run-commands). Only used with
  `script_metadata` on.

An unrecognized key raises a configuration error at build start.

Cross-references and hyperlinks resolve into absolute links using Sphinx's own
[`html_baseurl`](https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-html_baseurl).
Leave it unset and no links are generated anywhere.

Overriding a single key from the command line works via a dotted `-D` flag, e.g.
`-D sphinx_examples_as_code_conf.link_position=bottom` -- this only touches that one
key, leaving the rest (`conf.py`'s values, or the defaults) alone.

## Dependencies and run commands

With `script_metadata` on, each download lists the third-party projects its code
imports, plus any `dependencies` set for its format, and says how to run it:

- Python scripts start with a [PEP 723](https://peps.python.org/pep-0723/) `# /// script`
  block, followed by `# Run this script with: uv run <name>.py`.
- Jupyter notebooks say `` Open this notebook with: `uvx --python "<requires-python>" --from jupyterlab --with <dependency> ... jupyter-lab <name>.ipynb` `` in their first cell,
  with `--python` only when there is a `requires-python`. Arguments are double-quoted where
  needed, so the command works in bash, zsh, PowerShell and cmd. An argument containing
  `"`, `$`, a backtick, `\`, `%` or a `!` not followed by `=` is single-quoted instead,
  which cmd does not understand.
- marimo notebooks start with the same `# /// script` block, which also lists `marimo`,
  followed by `# Open this notebook with: uvx marimo edit --sandbox <name>_marimo.py`.
- Each run command is followed by a line linking to
  [uv's installation guide](https://docs.astral.sh/uv/getting-started/installation/).

Imports are read from the code, not run. Each imported top-level module maps to the
project that provides it in the environment building the docs; standard-library
modules, relative imports and modules no single installed project provides are left
out. Projects the code needs but never imports, such as an optional backend, go in
`dependencies`, or are imported explicitly in the example. In Python scripts and Jupyter notebooks, a dependency with no version or extras of
its own is left out when another listed one already installs it on every platform, e.g. `numpy`
next to `pyvista`. marimo notebooks list every dependency, since marimo's sandbox adds back any
it does not find by name.

The project named by Sphinx's own `project` setting (e.g. `project = 'PyVista'`) gets
extra treatment when an example imports it and it is installed: its dependency becomes
`<name>>=X.Y`, the installed release's major and minor version, or plain `<name>` for a
pre-release or dev build.

Unless `requires_python` is set, the block's `requires-python` combines:

- that project's own `Requires-Python`, e.g. `>=3.10`, when it is listed;
- `<3.N`, when the download depends on that project, or on any other dependency, listed or
  left out, installed from a wheel built for one Python version's ABI, e.g. `cp314-cp314`, in
  the environment building the docs. `N` is one minor version above the Python building the
  docs, or above the oldest of the newest Pythons their `Programming Language :: Python :: 3.X`
  classifiers name, whichever is newer; one naming no `3.X` doesn't count toward that oldest.
  Dependencies whose wheels carry the `none` or stable `abi3` ABI tag don't count, and the
  bound is dropped when it would exclude every Python the project's own `Requires-Python`
  allows.

With neither, the line is left out.

## Conversion rules

What happens to the content of an Examples section:

- Doctest blocks (`>>> ...` / `... ...`) keep their input lines, prompts stripped, as
  real Python source. Doctest *output* lines are dropped. In Python scripts, an
  expression that has output is wrapped in `print(repr())`, so running the script
  prints what the doctest shows. A call that prints its own output and returns `None`
  also prints `None`. In notebooks, a statement that has
  output ends its code cell.
- `.. code-block:: python` (or `py`) blocks are kept as-is, unless they start with `>>>`,
  which converts them like a doctest block; other languages become
  comments, set off with blank lines on both sides like any other directive.
- Admonitions (`.. note::`, `.. warning::`, ...) become a `# LABEL:` comment followed by
  their content as comments, indented one level under the label in Python scripts only.
- A bullet/numbered list becomes a `-`/`N.`-marked line per item, set off with a blank
  line on both sides in either format — a bare `#` instead of a real blank line when
  that falls inside an admonition or a definition's own body in a Python script, so the
  whole thing still reads as one unbroken comment block. A definition list's term stays
  at the surrounding indent; its definition (the body nested under it) is indented one
  level further in Python scripts, same as an admonition's own content.
- Cross-references and inline code (`:class:`, `:meth:`, `:func:`, `:attr:`,
  double-backtick literals, ...) keep their display text, wrapped in backticks (e.g.
  ``:class:`pyvista.Plotter` `` -> `` `pyvista.Plotter` ``). If `html_baseurl` is set and
  the reference resolves, notebooks turn it into a clickable link; Python scripts never
  write the link, only the display text.
- Plain prose-style references (`:ref:`, `:doc:`) are treated the same way, minus the
  backticks.
- Everything else text-bearing (prose, captions, other non-Python code) becomes a plain
  `#` comment.
- A table becomes an aligned text table, its caption on the line above: an RST simple
  table in Python scripts, a Markdown pipe table in notebooks. Each cell is flattened to a
  single line, and a column left entirely empty -- one holding only images, say -- is
  dropped along with its heading.
- Figures/images, raw HTML, sphinx-design dropdowns/tab-sets, and sphinx-tags' `.. tags::`
  line are dropped entirely.
- "See Also" content is always dropped.

Generated Python scripts start with a `# Examples from <qualified name>` title header
(gallery mode uses the page's own title instead -- see below), with a few whitespace
conventions: prose directly above a code block stays attached to it, a code block is
always followed by a blank line, and a directive (header, `# NOTE:`-style block) gets
blank lines on both sides.

Generated Jupyter notebooks use the same content, split into alternating code/markdown
cells instead.

Generated marimo notebooks are that same Jupyter notebook converted by marimo's own
converter, the one behind `marimo convert`. `from __future__` imports are dropped first,
so the notebook also runs as a plain script (`python <name>_marimo.py`).

A download link is only added if the resulting code contains at least one real
executable statement.

## Sphinx-Gallery integration

With `sphinx_examples_as_code_conf['gallery_downloads'] = True`, this extension takes
over the downloads on every page generated by
[sphinx-gallery](https://sphinx-gallery.github.io): the "Go to the end to download the
full example code" note and the `.py`/`.zip` download footer are removed from
the page, replaced with download link(s) built by this extension instead — same
conversion rules as above, applied to the whole page rather than one Examples section.
The generated file's header uses the page's own title (e.g. `# Create Circular Arcs`)
rather than the generic `# Examples from <docname>`.

The "Total running time" line and the "Gallery generated by Sphinx-Gallery" credit line
stay on the rendered page untouched, but never make it into the generated download
itself.

Detection is automatic and per-page — any page without a sphinx-gallery download footer
is left completely untouched. Gallery pages and ordinary docstring/prose pages (using
the Examples-section behavior above) can coexist on the same site.

A `# %%` cell with its own RST heading gets the same header treatment as the file's own
title, and renders as a real Markdown heading in notebooks. Level is relative to actual
RST section nesting, not always one below the file's own header: a cell heading nested
under the page's own title (the common case) is one level below it; one that reuses the
page title's own underline character is level 1, the same as the file's own header; a
cell heading nested under *that* is level 2 relative to it, and so on.

Each format uses one heading style consistently across every level, rather than mixing
styles:

- Python scripts use an RST-style title + underline, one character per level -- the same
  sequence Sphinx's own documentation uses for sections through sub-paragraphs: `=`, `-`,
  `~`, `^`, `"`, `'` for levels 1 through 6.
- Notebooks always use ATX syntax (`#`, `##`, `###`, ...) at every level.

Two things worth knowing before turning this on:

- It's built against sphinx-gallery's own doctree output — the `sphx-glr-*` CSS classes
  its own theming depends on, and the private node type holding a highlighted code
  block — not a documented extension API. A future sphinx-gallery release could shift
  that structure without warning. Only sphinx-gallery 0.22.x is tested, so the `gallery`
  extra caps there rather than letting the next release quietly produce no downloads.
- sphinx-gallery's own `.py`/`.zip` downloads still end up copied into `_downloads/`,
  even though nothing on the page links to them anymore. Its `.ipynb` is never written:
  with `gallery_downloads` on, this extension sets `sphinx_gallery_conf['pypandoc']` to
  `False` and `sphinx_gallery_conf['notebook_extensions']` to an empty set before
  sphinx-gallery reads them, so pandoc is not needed. Setting `pypandoc`,
  `notebook_extensions`, `first_notebook_cell`, `last_notebook_cell` or `notebook_images`
  yourself has no effect and is reported as a warning.

## Auditing the downloads

`sphinx-examples-as-code-audit` runs every download of a built HTML tree in the
documentation's own environment, with plotting off screen, and reports what is wrong with
each. The `audit` extra installs what it needs to run notebooks:

```bash
pip install 'sphinx-examples-as-code[audit]'
sphinx-examples-as-code-audit run _build/html/_downloads --html _build/html --format python --output python.jsonl
sphinx-examples-as-code-audit run _build/html/_downloads --html _build/html --format jupyter --output jupyter.jsonl
sphinx-examples-as-code-audit run _build/html/_downloads --html _build/html --format marimo --output marimo.jsonl
sphinx-examples-as-code-audit pages _build/html --output pages.jsonl
sphinx-examples-as-code-audit report *.jsonl --output report.md
```

With `--html`, only downloads a page links to are run. Each finding has a kind:

| Kind | Finding |
| --- | --- |
| `syntax` | Code that is not valid Python, such as a doctest prompt or a `%` line |
| `magic` | A line the marimo converter turned into an IPython magic |
| `marimo-check` | `marimo check` fails, e.g. a name defined in two cells |
| `dependency` | A script fails importing a module its `dependencies` do not install |
| `requires-python` | A download's `requires-python` excludes the Python running the audit |
| `collapsed` | A doctest value is not the last statement of its notebook cell, so it is not shown |
| `dropped-output` | A script does not print a doctest value |
| `mismatch` | A notebook shows a value that differs from the doctest's expected output while the script's matches |
| `python-mismatch` | The script's value differs too |
| `marimo-rename` | marimo renamed a name to a private `_name` and the cell raises `NameError` |
| `error` | A notebook fails while the script of the same example runs |
| `python-error` | The script fails too |
| `timeout` | A download stage runs longer than `--timeout` seconds |
| `missing-download` | A link group or gallery page lacks a format other pages have |
| `no-downloads` | No download of the format exists at all |

- Scripts run one top-level statement at a time. An import of a module that only
  distributions outside the `dependencies` of the script's inline metadata provide fails,
  as it would in the environment `uv run` builds.
- Downloads run in the current environment and user account. Pass `--exclude` with glob
  patterns of example names to skip any that delete or overwrite files, e.g. a cache.
- Expected outputs come from the doctest blocks of each download's example, compared with
  `ELLIPSIS`, `NORMALIZE_WHITESPACE` and pytest's `NUMBER`. Pass `--link-position bottom`
  when `link_position` is `'bottom'`.
- `report` lists findings by kind and fails on all of them but `python-mismatch` and
  `python-error`, which the documentation build itself covers; pass `--allow` to choose.

The reusable workflow `.github/workflows/audit-downloads.yml` runs this on a docs artifact
of the calling repository, or of `repository`, one job per format, and can file the report as
an issue. Each job runs `install` in a checkout of the docs repository, at the calling
workflow's ref when `repository` is empty and at its default branch otherwise, then installs
the audit from the workflow's own commit:

```yaml
jobs:
  audit:
    uses: pyvista/sphinx-examples-as-code/.github/workflows/audit-downloads.yml@v0.12.0
    permissions:
      actions: read
      contents: read
      issues: write
    with:
      artifact: docs-html
      install: uv pip install . --group docs
      headless-display: true
      issue-label: docs-download-failure
```

## Development

```bash
uv sync --group dev --extra gallery
uv run pytest
uv run pre-commit run --all-files
```
