Skip to content

How-to guides a reader can paste, with the checks off the page - #736

Open
tony wants to merge 18 commits into
masterfrom
tested-doc-examples
Open

How-to guides a reader can paste, with the checks off the page#736
tony wants to merge 18 commits into
masterfrom
tested-doc-examples

Conversation

@tony

@tonytony commented Aug 9, 2026

Copy link
Copy Markdown
Member

Summary

  • Add a docs/howto/ section: fifteen task-shaped guides whose visible code is plain Python a reader copies into a file and runs — no >>> prompts, no assertions, no test markers.
  • Add a pytest plugin that runs each page's blocks in document order in one shared namespace, so block 2 sees what block 1 bound, which is what a reader pasting in sequence gets.
  • Pair every page with a sidecar module holding the hidden checks. A missing sidecar, a missing check, or a check with no block is a hard failure rather than a skip.
  • Isolate each page in a private TMUX_TMPDIR and HOME from the runner rather than from page setup, so an example cannot reach the tmux of whoever runs the suite.
  • Guard the contract with three test modules covering the page/sidecar wiring, the rendered HTML, and — by running pages against a deliberately broken libtmux — that the hidden checks can actually fail.
  • Fail the docs build on warnings, so a broken cross-reference or a page in no toctree stops the build instead of scrolling past.

Prose pages under docs/ are doctests, which are testable but not copyable — a reader cannot paste >>> server.new_session() into a script. A how-to guide needs the opposite, and the doctest collector only claims a fence containing >>>, so a bare ```python block would ship untested with no warning and no failure.

Changes by area

The harness

  • tests/docs/howto_harness.py: parses a page with MyST's own parser, collects its ```python fences in document order, runs them in one namespace, and calls the sidecar's check_N(ctx) after block N. Its pytest_collect_file wrapper replaces the aggregated collector list for how-to pages so the doctest plugin does not also claim them.
  • The parser reads myst_enable_extensions from docs/conf.py rather than restating it. Whether a run of backticks becomes a fence depends on the enabled extension set, so a copy would drift the first time the site's config changed — and drift means a block that renders on the page but never runs.
  • ctx.run_block(n, namespace={}) re-runs a block in a fresh world, which is how a page showing if server.is_alive(): gets both branches checked from one visible block.

The guides

Fifteen pages, grouped in the index by what you are driving:

GroupPages
Serversstart a server, run multiple servers, connect to an existing server, check if tmux is running
Lookupsconnect to an existing session, window, pane
Self-locationdetect you are inside tmux; find the session, window, pane you're in
Panessend keys, send keys to every pane, create panes, create a floating pane

The guards

  • tests/docs/test_howto_harness.py: the tree is non-empty, every page's contract resolves, every page the run was pointed at produced an item, each page opens with the anchor its filename implies, and each appears in the toctree beside it.
  • tests/docs/test_howto_rendering.py: rejects assertions, pytest imports, harness names and prompts in a visible block; rejects any line the sphinx-copybutton prompt pattern matches, read from docs/conf.py; and allows only python and console as rendered languages.
  • tests/docs/test_howto_mutations.py: re-runs each page that types a command and reads the answer back against a libtmux whose send_keys never presses Enter, and requires the page's own checks to fail.

Conventions and build

  • docs/AGENTS.md: carves out docs/howto/ from the doctest rules that govern the rest of docs/, and states what visible code owes a reader who runs it verbatim.
  • docs/justfile: passes -W to sphinx-build.

Design decisions

The page carries no test scaffolding at all. The pages are the product; an assertion in the block is an assertion in the reader's paste. Everything that makes a page testable lives in tests/docs/howto/<slug>.py, named after the page.

Blocks share one namespace, which inverts the rule for the rest of docs/. Topic pages give every block a fresh namespace and a fresh server, because each is an independent illustration. A how-to page is one script broken into steps, so its second block must see the first block's names.

Isolation belongs to the runner, not the page. A private TMUX_TMPDIR and HOME are set before the sidecar is called, and only sockets under that directory are killed, only while the environment still points at it. A sidecar that had to ask for isolation is a sidecar that can forget.

A mutation probe, because a passing check is not evidence.capture_pane returns the command tmux echoed onto the pane, so a containment test for the answer is satisfied when the keystrokes land — before any shell runs, and equally if none ever will. Without a probe the page reports green while teaching a technique that does not work.

Visible code polls to a deadline and compares whole lines. A fixed sleep is either dead time or a false negative, and a substring test is true before the shell has run. The pages teach the correct shape because a reader runs them verbatim.

Verification

No page carries a doctest prompt — expect no matches:

$ rg '^>>> ' docs/howto/

No page carries an assertion — expect no matches:

$ rg '^\s*assert ' docs/howto/

No colon fence, which renders copy-pasteable but executes nothing — expect no matches:

$ rg '^:::python' docs/howto/

Every guide has a sidecar — both counts are fifteen:

$ fd -e md . docs/howto | rg -v 'index.md'| wc -l
$ fd -e py . tests/docs/howto | rg -v '__init__'| wc -l

Test plan

  • uv run pytest --reruns 0 — full suite green
  • uv run ruff check . and uv run ruff format --check . — clean
  • uv run mypy src tests — clean
  • just build-docs — builds under -W with no warning and no broken cross-reference
  • test_howto_mutations — every page that reads output back fails when send_keys stops pressing Enter, proving the hidden checks are load-bearing
  • test_howto_harness — a page with no sidecar, a missing check_N, and an orphan check_N each fail rather than skip
  • test_rendered_blocks_match_the_page_source — Sphinx renders each page's python blocks exactly as written
  • Pages pass under pytest -n auto, matching CI's distribution

tony added 13 commits August 8, 2026 10:00
why: Prose examples under docs/ are doctests: testable, but a reader
cannot paste `>>> server.new_session()` into a script. A how-to guide
needs the opposite — plain Python a reader copies verbatim — and
gp-libs collects a fence only when it contains `>>>`, so a bare
```python block ships untested with no warning and no failure.
what:
- Add tests/docs/howto_harness.py, a pytest plugin that parses a page
with MyST's own parser and runs its backtick-fenced ```python blocks
in document order in one shared namespace, so block 2 sees what
block 1 bound
- Pair each page with a sidecar module holding the hidden checks; a
missing sidecar, a missing check_N, or a check_N with no block are
all hard failures rather than skips
- Point tmux at a private TMUX_TMPDIR and HOME from the runner, not
from each page's setup(), so isolation cannot be forgotten
- Kill only sockets found under that private directory, and only while
the environment still points at it
- Reject a colon fence that is not a directive: `:::python` renders a
copy-pasteable block the harness would never execute
- Replace the aggregated collector list for how-to pages so gp-libs
does not also claim them
why: Checking whether a server is alive is the first thing anyone does
against a Server handle, and it is the page that proves a visible
block can branch without a visible assert: both sides of the branch
are exercised, neither is shown.
what:
- Add docs/howto/index.md with a card grid and a hidden toctree, and
register it in the root toctree and the front-page grid
- Add docs/howto/check-if-tmux-is-running.md covering is_alive() for
the branch and raise_if_dead() for the reason, and noting that an
empty sessions list means "no sessions or no server"
- Warn that `with Server()` kills the server it was asked about, since
Server.__exit__ takes it down with no opt-out
- Add tests/docs/howto/check_if_tmux_is_running.py, which runs the
visible block against a live private server and then re-runs it in a
throwaway namespace with the server killed
why: Fanning a command across a split window is the first task that
needs state to survive from one block to the next, so it is the page
the shared namespace exists for. It is also where the obvious example
is quietly wrong: capture_pane returns the command tmux echoed onto
the pane, so a substring test for the answer is true before any shell
has run, and a fixed sleep is either dead time or a false negative.
what:
- Add docs/howto/send-keys-to-every-pane.md: three blocks that build a
three-pane window, type into every pane, and wait for each answer
- Poll against a deadline in the visible block, and compare whole
captured lines, explaining in prose why a substring test lies
- Put the window on Server(socket_name="libtmux-howto") with
kill_session=True, so the example cannot reach an existing tmux and
survives being pasted twice
- Have each pane echo $TMUX_PANE, so the output proves the command
landed in three panes rather than three times in one
- Add tests/docs/howto/send_keys_to_every_pane.py, which verifies the
result of the page's own wait rather than repeating it
why: The harness only enforces its contract for pages it is asked to
run, so it cannot see itself being unplugged. Nor can it see a page
that is reachable by nobody: an orphan page and a stale anchor are
Sphinx warnings, and the docs build does not fail on warnings.
what:
- Add tests/docs/test_howto_harness.py asserting the tree is non-empty,
every page's contract resolves, and every page the run was pointed at
produced a harness item
- Require each page to open with the (howto-<stem>)= anchor its
filename implies, so a rename breaks here rather than silently
orphaning inbound {ref} links
- Require each page to appear in the toctree of the index beside it
why: Three of the promises a how-to page makes are properties of the
rendered page, not of running the code: that the block holds nothing
but the reader's own script, that the copy button returns it verbatim,
and that every block a reader can copy is one something tests. All
three held only by inspection.
what:
- Add tests/docs/test_howto_rendering.py rejecting assertions, pytest
imports, harness names and doctest prompts in a visible block, and
naming the sidecar the check belongs in instead
- Reject any line the sphinx-copybutton prompt pattern matches, since
it also copies only prompted lines: a block opening with a
column-zero `# comment` copies as its comments alone
- Read that pattern from docs/conf.py, so the test tracks the theme's
configuration rather than a copy of it
- Capture each rendered block with the language Sphinx tagged it with,
and allow only ```python, which the harness runs, and ```console,
which the reader runs; every other spelling is rejected by name,
since ```py renders as highlight-py and a bare ``` as
highlight-default, and the harness executes neither
- Add an integration-marked build asserting Sphinx renders each page's
python blocks exactly as written, scoped to the how-to pages to stay
affordable enough for the ordinary test run
why: A check on a page that types into a pane can pass for the wrong
reason. capture_pane returns the command tmux echoed onto the pane, so
a containment test for the answer is satisfied when the keystrokes
land — before any shell runs, and equally if none ever will. The page
then reports green while teaching a technique that does not work, and
the mistake is invisible in review.
what:
- Add tests/docs/test_howto_mutations.py, which re-runs each page that
types a command and reads the answer back against a libtmux whose
send_keys never presses Enter, and requires the page's own checks to
fail
- Select those pages by inspecting their blocks, so a page added later
is covered without anyone remembering this file exists, and assert
the selection is non-empty
- Extract run_page() from HowtoPageItem.runtest so a test can drive a
page without going through collection
why: docs/AGENTS.md tells authors that every code block on a page is an
independent doctest with a fresh namespace and a fresh server. That is
the opposite of how docs/howto/ works, so an author following it would
write a page of self-contained snippets and lose the thing the section
exists for — code a reader pastes in sequence.
what:
- Carve out docs/howto/ in docs/AGENTS.md: plain ```python fences, no
scaffolding on the page, one shared namespace in document order, and
the sidecar contract with its failure modes
- State what visible code owes a reader who runs it verbatim: a named
socket where anything is created, kill_session=True, a deadline poll
rather than a sleep, whole-line matching, and no column-zero `# `
- Point the root AGENTS.md doctest rules at the exception
why: A broken cross-reference, a page in no toctree, and a directive
option Sphinx could not parse are all warnings it builds through, so
the docs build reported problems nobody read. The how-to guards catch
those cases inside docs/howto/ only; -W covers the rest of the site.
what:
- Pass -W to sphinx-build from docs/justfile, which every builder there
already shares
why: The section is about to hold fifteen recipes. A single flat grid
makes a reader scan every card to find the one about servers, and
gives no hint where a new page belongs.
what:
- Split the how-to index grid into headed sections, starting with
Servers and Driving panes
why: Starting a server, running several at once, and pointing a handle
at one somebody else started are the questions that come before every
other recipe, and each has a trap that costs an afternoon.
what:
- Add start-a-server: new_session() is what boots the daemon, and every
server started from Python is already detached because a script has
no terminal to attach to. start_server() leaves nothing running --
exit-empty is on by default, so a sessionless daemon exits before the
next command reaches its socket
- Add run-multiple-servers: a socket, not a session, is tmux's
isolation boundary. socket_name never populates socket_path, and
Server.__eq__ compares both, so two handles for one daemon built
different ways are not equal
- Add connect-to-an-existing-server: tmux leaves the socket file behind
when a daemon exits, so a directory listing is a list of candidates,
not of servers; and because sessions is lenient, a dead socket reads
exactly like a healthy server with nothing running on it
why: Finding a session, window or pane that already exists is the other
half of connecting to a server, and QueryList's two failure modes are
not what a reader expects from a dict-like get().
what:
- Add connect-to-an-existing-session: get(default=...) covers absence
but not ambiguity -- MultipleObjectsReturned is raised even with a
default -- and an empty sessions list means "no sessions or no
server", so is_alive() is what separates them
- Add connect-to-an-existing-window: a window's index is its position
and its name is a label, neither is unique across sessions, and
window_index compares as a string, so windows.get(window_index=1)
quietly finds nothing
- Add connect-to-an-existing-pane: window.panes is ordered by pane
index, which is layout position rather than creation order, so the
pane you just split is not reliably the last one
why: Code running inside a pane -- a hook, an agent, a script launched
in a split -- starts holding no handle at all. tmux already answered
the question in the environment, and the four from_env() calls read it
back, but the reader has to know they exist before they go searching
the server for themselves.
what:
- Add detect-you-are-inside-tmux: branch on whether your process was
spawned by a pane, and note that from_env() reads the environment
without touching tmux, so it proves ancestry rather than reachability
- Add find-the-session-youre-in: Session.from_env(), and why parsing
the session id out of $TMUX yourself is wrong -- tmux writes it once
at pane spawn and it goes stale when the window moves
- Add find-the-window-youre-in: Window.from_env(), and that a window
can belong to more than one session
- Add find-the-pane-youre-in: Pane.from_env(), and why it is not
active_pane, which means whichever pane has the focus
why: Typing into a pane and splitting one are the two things most
libtmux code does, and both have a trap that a working-looking example
hides: reading output back can match the shell's echo of the command,
and pane order is layout position rather than creation order.
what:
- Add send-keys: type into one pane, stage a command with enter=False,
and read the answer back by polling to a deadline and matching whole
lines rather than substrings
- Add create-panes: split a window into a layout, keep the panes split
returns rather than looking them up again, and note that panes are
listed in layout order
- Add create-a-floating-pane: new_pane() hovers a pane over the layout
on tmux 3.7+, gate on has_gte_version so the version error arrives
before the pane is half-built, and drive it like any other pane
@codecov

codecovBot commented Aug 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.48739% with 31 lines in your changes missing coverage. Please review.
✅ Project coverage is 57.31%. Comparing base (729e5c3) to head (91c7f37).

Files with missing linesPatch %Lines
tests/docs/howto_harness.py81.97%21 Missing and 10 partials ⚠️
Additional details and impacted files
@@ Coverage Diff @@## master #736 +/- ##
==========================================
+ Coverage 52.37% 57.31% +4.94% 
==========================================
Files 26 42 +16 Lines 3729 4203 +474 Branches 747 778 +31 ==========================================
+ Hits 1953 2409 +456 - Misses 1472 1482 +10 - Partials 304 312 +8 

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

tony added 5 commits August 9, 2026 05:08
why: The how-to section is a new documentation surface a reader
chooses to visit, so it needs an entry that says what is there and
why the examples can be trusted.
what:
- Add a Documentation deliverable naming the section, the ground it
covers, and that every page runs as part of the suite
why: tmux 3.4 answers `split-window -p40` with `size missing`, so
passing percentage= to Pane.split or Window.split raised
LibTmuxException there. tmux taught -l to take a percentage in 3.1 and
deprecated -p in the same release, so -l is the one spelling that
works across every version libtmux supports.
what:
- Emit `-l{percentage}%` instead of `-p{percentage}`
- Drop test_split_percentage's 3.5 skip, which existed to route around
the tmux 3.4 regression and would otherwise hide this fix on the
versions it repairs
- Stop naming the tmux flag in the parameter docs, since the mapping is
no longer one-to-one
why: A guide to a version-specific feature cannot run everywhere. The
floating-pane page opens by refusing to go on without tmux 3.7, which
is the behaviour it teaches -- and on an older tmux that refusal came
back as a suite failure rather than as the page being inapplicable.
what:
- Read an optional MIN_TMUX_VERSION from a page's sidecar and skip the
page below it, naming the page, the floor and the tmux in use
- Check it after the page/sidecar contract resolves, so a missing check
or a stray one still fails at every tmux version; only running the
examples is skipped
- Declare 3.7 on the floating-pane sidecar
The declaration sits on the sidecar rather than the page because the
page carries no scaffolding: a reader copying the block should meet the
feature, not a marker addressed at the test suite.
why: tmux says `no space for new pane` through 3.6 and `size or
position no space for a new pane` from 3.7. The page quoted the newer
form as if it were the wording, and its check asserted on it, so both
were wrong for a reader on anything older.
what:
- Assert on the fragment every supported version shares
- Describe the failure in prose rather than quoting one release's text,
and tell a reader who branches on the message to match loosely
why: The percentage argument shipped in 0.56 and has never worked on
tmux 3.4, so anyone on that version hit it.
what:
- Add a Fixes entry for percentage sizing on Pane.split and Window.split
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@tony