Skip to content

Doc examples — every snippet is tested

Every Python, C, and shell code example in the docs is checked in CI. This is not a convention you have to remember — it is enforced by discovery: a gate scans every page under docs/ and runs its fences, so a new page is covered the moment it exists. There is no opt-in list. The Python gate lives in src/doppler/tests/test_doc_snippets.py, the C gate in src/doppler/tests/test_c_doc_snippets.py, and the shell gate in src/doppler/tests/test_sh_doc_snippets.py (all sharing include-resolution and marker-parsing logic via src/doppler/tests/_docs_snippet_common.py); run any of them locally with:

uv run pytest -m docs_snippets                            # all gates
uv run pytest -m docs_snippets test_c_doc_snippets.py      # C only

The shell gate covers the third fence class — documented CLI invocations (```sh/```bash/```console): every doppler .../doppler-specan ... line is parsed against the CLI's real argparse parser (build_parser() — unknown flags, missing positionals, and bad choices all fail), and a fence whose commands are all safe (wfmgen, cat, …) with no live transport executes end-to-end under bash -e in a throwaway per-page cwd. A ```json title="scene.json" fence is materialized into that cwd first, so "here is the spec file, here is the command that consumes it" runs exactly as shown. This class is where the quickstart's compose CLI bugs (#458) and two wrong commands on the architecture page lived — none of them could ever have worked. A fence that depends on context outside shell (a file a Python fence wrote, an unbounded --realtime stream) takes <!-- docs-snippet: no-exec=REASON -->: still parse-validated, never executed. Reasons are mandatory on every marker, in every gate.

The C gate needs the library built first (make build) — it compiles each ```c fence against build/libdoppler.a (and build/libdoppler_stream.a, when a snippet uses the stream/stream.h wire layer) with -std=gnu99 -Wall -Wextra -Werror, then runs the binary and requires exit code 0. -Werror means a warning is a failure here, same as a compile error — this is what caught the homepage's own broken C "Quick start" snippet (see below).

Why

Doc snippets rot silently. The quickstart once showed HalfbandDecimator(); the constructor later gained a required argument, the example started raising TypeError, and nothing noticed for weeks. Prose examples are the first thing a new user runs — they must always work. The homepage's own C "Quick start" snippet had the same problem in a different language: missing #include <complex.h>, undeclared arrays, and top-level function calls outside main() — three separate compile errors — because nothing compiled it. "Quick and easy" that silently doesn't compile is worse than not showing C at all; the C gate below closes that hole the same way the Python one already did.

The four states of a fence

Every ```python / ```pycon block in docs/ is in exactly one of these. They layer: exec proves the snippet still runs; the other three also prove it still shows the right result.

State How Proves Use for
exec plain ```python the gate runs it it runs
doctest a >>> session the gate checks output it runs and the value is right
include --8<-- from a tested .py byte-identical to code CI already runs zero drift, by construction
skip <!-- docs-snippet: skip=REASON --> nothing (documents why)

Pseudocode is not a state — write it as ```text so it is out of scope.

Runnable-first — how to choose

A skipped block is not tested, so it can rot exactly like an unguarded one. The whole point of the gate is to run the code, so reach for skip= last, not first. When a block fails the gate, work down this list — stop at the first that fits:

  1. --8<-- from a tested example script — the gold standard. Almost every gallery page mirrors a src/doppler/examples/*_demo.py that already runs in CI; pull the code in and the shown code is the tested code, forever.
  2. exec with one line of real setup — most "fragment" failures are a real API call one undefined name away from running (c.steps(rx) where rx is never defined). Define rx for real (rx = LO(0.05).steps(4096)) and the block genuinely tests steps. Do not invent a fake value that merely silences the error — a block passing against a bogus rx hides drift instead of catching it.
  3. doctest — same, but when the value is the point.
  4. skip= — only when the code genuinely cannot run headless: a blocking recv(), a hardware source, a two-terminal demo, or a read of a capture file the reader supplies. A steps()/accumulate()/push() call is not one of these — make it runnable instead.

Anti-pattern: do not add a hidden shared setup namespace so fragments "just run". A block that passes against an injected global you can't see is worse than untested — it looks green while masking a rename. Keep shown code and run code identical; that is what --8<-- guarantees for free.

exec — the default for inline prose

Just write a normal fenced block. A page is one notebook: its fences share a namespace and run top to bottom, so a later block may use names an earlier block bound. The gate runs in a throwaway working directory (writing a file is safe) with numpy.random.seed(0) for determinism and a per-block timeout.

doctest — when the value matters

>>> import numpy as np
>>> from doppler.source import LO
>>> np.round(LO(0.25).steps(4), 3)   # fs/4 tone: 1, j, -1, -j
array([ 1.+0.j, -0.+1.j, -1.-0.j,  0.-1.j], dtype=complex64)

Standard # doctest: directives (+SKIP, +ELLIPSIS, …) are honored — round or use +ELLIPSIS for values with floating-point noise.

include — zero drift

The strongest guarantee: show code that is already tested. Every script in src/doppler/examples/*.py runs in CI on arrival — the example gate (src/doppler/tests/test_examples.py, make test-examples-python) discovers them by glob, exactly like this gate discovers pages; the only way out is a reasoned entry in src/doppler/examples/.examples-skip. Examples are required to validate themselves (assert on a BER threshold, a lock flag, a round-trip equality), so exit 0 means "demonstrated and checked". Most gallery pages include their code from these scripts, so page, script, and committed figure are one artifact. Mark a self-contained region (imports included) in the tested script:

src/doppler/examples/lo_demo.py
import numpy as np
from doppler.source import LO

lo = LO(0.25)  # a free-running quarter-rate tone
iq = lo.steps(8)  # 8 complex64 samples: 1, j, -1, -j, repeating

Then pull it into the page with a single --8<-- line inside a python fence. The docs build inlines it — and the gate resolves it too, so the block is really executed against the same code CI already tests. This very block is live:

import numpy as np

from doppler.source import LO

lo = LO(0.25)  # a free-running quarter-rate tone
iq = lo.steps(8)  # 8 complex64 samples: 1, j, -1, -j, repeating

The shown code is the tested code, so it cannot drift. Prefer this for new gallery pages and any excerpt whose correctness (not just runnability) matters. Note the region must run standalone — the gate executes exactly the marked lines, so include the imports.

skip — the last resort

For blocks that cannot run headless — a blocking network recv(), a hardware source, a two-terminal demo, an intentionally-wrong example. Put the marker on the line immediately before the fence. The reason is mandatory (a bare marker fails the gate), so every exclusion is reviewed in the diff:

<!-- docs-snippet: skip=blocking NATS recv, needs a broker; round-trip covered by stream tests -->

A block can also assert it raises: <!-- docs-snippet: raises=ValueError -->.

broker — conditional, not dead

<!-- docs-snippet: broker=REASON --> is for a single-process block that needs only a live NATS broker: it runs whenever 127.0.0.1:4222 is reachable — CI's python-tests job starts a JetStream broker, so it IS executed in CI — and skips elsewhere. Same idiom as the stream suite and the example gate's broker: registry entries. Use skip= instead when the block needs a peer process (a two-terminal demo) or is an illustrative fragment whose names come from prose.

C fences

C has no REPL and no doctest notion, so a ```c fence has five states instead of Python's four — no per-page shared namespace either, since each fence is a fully independent compile-and-run:

State How Proves Use for
exec plain ```c with its own int main(void) compiles + runs, exit 0 it builds and runs
include --8<-- from a tested native/examples/*.c byte-identical to code make test-examples already runs zero drift, by construction
broker <!-- docs-snippet: broker=REASON --> compiles everywhere; runs where a broker is on :4222 complete program that talks to a broker
no-run <!-- docs-snippet: no-run=REASON --> compiles -Werror against the real headers + link line complete program whose run blocks on a peer
skip <!-- docs-snippet: skip=REASON --> nothing (documents why) genuine fragments (no main, undeclared names)

The same "runnable-first" bias applies: a fragment missing main() or an #include is usually one edit from genuinely compiling — prefer fixing it over skipping. A complete program that only can't run headless takes no-run= (blocking recv() on a live peer) or broker= (needs only a broker — CI provides one), both of which still compile it with the full -Werror consumer recipe; skip= drops even the compile check, so it is for genuine fragments only (a struct-layout excerpt, or a listing of placeholder names like dp_foo_create that no header declares).

Signature listings — pin them, don't quote them

A quoted prototype is the most rot-prone thing a page can contain. It reads as authoritative, nothing links to it, and it can be wrong for a year before anyone notices — skip= is exactly the marker that lets it happen, because it turns off the only check that would have caught it.

If the functions are real and declared in a header, don't quote them. Bind each to a spelled-out function pointer inside a main(), so the page is a compiled program and the fence fails CI the moment a signature drifts:

a self-verifying signature listing
#include <complex.h>
#include <stddef.h>
#include <stdio.h>

#include "lo/lo_core.h"

int
main (void)
{
  size_t (*gen) (lo_state_t *, size_t, float complex *, size_t) = lo_steps;
  printf ("pinned: %d\n", gen != 0);
  return 0;
}

The types must be written out rather than inferred — that is what makes the compiler compare them against the header. It costs about four lines over a quoted prototype and converts a comment into a gate.

This is not hypothetical. Converting the two listings that qualified found both of them already wrong:

  • corr2d-interpolated-inverse.md had corr2d_create's parameters in a stale order (ny_out, nx_out before dwell, nthreads; the header has them after). All four are integers, so code copied from the page would have compiled and silently misconfigured the correlator.
  • acq-fn.md documented a bare acq_create that no longer exists — it was split into acq_create_burst/acq_create_continuous — carrying a max_noncoh parameter that had been removed outright.

Reserve skip= for listings that cannot be pinned: placeholder names (dp_foo_*, <component>_*), struct layouts, and proposed API that no header declares yet. When you use it for a signature listing, say which of those it is in the reason, so the next reader can tell "not pinnable" from "nobody got round to it".

The burn-down backlog

Pages not yet brought under a gate are listed in an ignore file — a temporary backlog that shrinks to empty, the same idiom as docs/api/.api-coverage-ignore and scripts/.serializable-ignore: docs/.doc-snippet-ignore for Python, docs/.c-doc-snippet-ignore for C. A new page is never added to either; it is gated on arrival. Each run prints doc-snippet backlog: N page(s) not yet gated. To retire a page, make its fences pass (or skip=-mark them with reasons) and delete its line.

Building the docs locally (gotchas)

The docs toolchain has a few sharp edges — all now guarded, recorded here so they are not rediscovered the hard way:

  • docs/api.md is generated by just-makeit, not hand-written — don't delete it (the manifest-drift gate will fail).
  • make docs / docs-serve need the docs dependency group (it holds zensical + the material theme). The Makefile targets pass --group docs; a bare uv run zensical in a dev-only venv renders themeless (no left nav).
  • A stale local zensical.toml shadows mkdocs.yml (zensical prefers it), silently truncating the nav. The docs/docs-serve targets rm -f zensical.toml first.