Skip to content

Migrate the CLI from argh to cw - #2

Merged
thorwhalen merged 4 commits into
masterfrom
migrate-to-cw
Aug 30, 2026
Merged

thorwhalen merged 4 commits into
masterfrom
migrate-to-cw

Conversation

@thorwhalen

@thorwhalen thorwhalen commented Aug 30, 2026 •

Copy link
Copy Markdown
Owner

Replaces argh (LGPL-3.0-or-later) with cw (MIT, zero runtime dependencies). Nothing a user sees changes.

The proof

misc/cli_golden_py310.json and misc/cli_golden_py312.json were recorded from the argh CLI before any source edit (their own commit, first in the branch) and replayed after each wave:

stage 3.12 3.10 --help
Wave 0 — from cw import compat as argh 18/18 identical — no changes
Wave 2 — cw.dispatch(run, argv) 18/18 identical 18/18 identical no changes

--help diff: none — asserted, not eyeballed: strict_help=True compares the full help body, and python -m cw.testing diff-help prints no --help changes. No expect_diff entries. 38 passed (37 pre-existing + the new parity test).

The corpus — 18 cases, in misc/cli_cases.txt

  • tier 3: --help. grub is one command, so this is its whole grammar;
  • the no-query interactive prompt — grub's headline behaviour, recorded with stdin closed: banner, one prompt, exit 0 on EOF;
  • searches through every flag, in both spellings argh derives — --method/-m, --chunk/-c, --extensions/-e, --snippets, and --n, the long form argh makes for a one-character parameter name;
  • a multi-word query as separate argv items, which is what *query catches;
  • grub's own bad-source path, which exits 1, not argparse's 2;
  • five usage errors, all still exiting 2.

The argv handoff, which is the one thing that could have gone wrong

argh took it as a keyword (argh.dispatch_command(run, argv=argv)); cw takes it as the second positional (cw.dispatch(run, argv)). main still returns an int and still returns 0 on success, so test_cli_main_entry_point's main([...]) == 0 holds and the console script's exit code is unchanged.

One golden per CPython version

argparse is stdlib and rewrites its own text between versions: 3.12 stopped listing nargs='*' positionals among "the following arguments are required". That is the only difference between the two recordings, it predates this migration, and argh produces it identically — verified against plain argparse on 3.10/3.11/3.12/3.13 with neither argh nor cw in the picture. A single golden asserted across a matrix would fail for something nobody caused, which is the fastest way to teach a team to ignore a red parity test.

The 3.10 golden is recorded from argh too — a throwaway 3.10 venv over a worktree pinned at the pre-migration commit — so it is a migration proof on both matrix versions, not a baseline taken after the fact. An unrecorded version fails loudly with instructions rather than skipping.

It cannot rot

tests/test_cli_parity.py replays in the ordinary test run, so a future cw release cannot silently change this CLI. Verified it can fail: renaming snippets to snippet turned it red naming exactly that flag.

Worth recording that the first attempt at a negative test — forcing convention=cw.MODERN — did not fail, and that is correct rather than alarming: for a single command whose options are all keyword defaults, cw's two conventions produce identical grammar. A negative test that cannot distinguish anything is worth catching before it is trusted.

Two cw defects this migration found

Both fixed in i2mint/cw#34, both found here:

  1. cw.testing inherited the recorder's stdin. grub's no-query REPL therefore recorded a timeout at a terminal and an EOF under CI — the same case, two different "facts". That made goldens non-reproducible between a developer's machine and CI, which is the exact failure mode RECORDING_ENV exists to prevent.
  2. Windows' .exe was not scrubbed. argparse takes prog from basename(sys.argv[0]), so the runner reported usage: grub.EXE. (This one bit the sibling opsward migration first — 22 of 31 cases red.)

Also

  • dependencies: argh → cw>=0.1,<0.2 (the fleet-wide spelling).
  • rg -n 'argh' grub/ returns nothing.
  • tests/fixtures/corpus/ — four one-line documents, well separated by topic, so a search case has a single unambiguous top hit.
  • misc/README.md documents the harness: how to read a red result (you changed the CLI / scikit-learn moved a score / you changed the CLI by accident), how to re-record, and the four rules for adding a case.

One case was written and deliberately removed: --extensions .md over a .txt-only corpus raises ValueError: empty vocabulary out of scikit-learn, and the uncaught traceback names 17 absolute paths — unreplayable on another machine, and it names the dispatcher's own frames, so it could never have survived this migration regardless. Worth knowing that rough edge exists; it is not this PR's to fix.

Checks

Validation 3.10 · Validation 3.12 · Windows Tests — all pass.

Windows deserves a note: it was red before cw 0.1.1 (usage: grub.EXE ... on every case that prints a usage line) and is green after, with no expect_diff and nothing skipped. The whole corpus — exit codes, stdout, stderr, usage lines and the full --help body — is asserted on all three.

18 argv-level cases recorded from the current argh-based console script: the full
`--help` surface, the no-query interactive prompt (grub's headline behaviour), a
search through each flag that reaches the searcher and each that reaches the
Searcher constructor, both spellings argh derives for every parameter (including
`--n`, the long form for a one-character name), grub's own exit-1 bad-source path,
and five usage errors that must keep exit code 2.

Recorded first, on purpose: this is the baseline the migration is measured
against, so it has to exist in history before any source changes.

`tests/fixtures/corpus/` is four one-line documents, well separated by topic, so a
search case has a single unambiguous top hit. `misc/README.md` documents the
harness: how to re-record, and the four rules for adding a case.

One case was written and then deliberately removed: `--extensions .md` over a
.txt-only corpus raises `ValueError: empty vocabulary` out of scikit-learn and the
uncaught traceback names 17 absolute paths -- unreplayable on another machine, and
it names the dispatcher's own frames, so it could never have survived this
migration regardless. Worth knowing that path exists; it is a grub rough edge for
another day, not something to pin.

Claude-Session: https://claude.ai/code/session_01K6LB3AwUmKDxaFNZ2NqPGr
`from cw import compat as argh` -- nothing else changes, including the `argv=`
keyword `dispatch_command` is called with here. Replayed against the golden
recorded in the previous commit:

    18/18 identical   (--strict-help, so the --help body is asserted too)

Kept as its own commit because it is the evidence for the claim: the compat shim
is a drop-in, and any behaviour difference in the next commit is attributable to
the move to cw's own API rather than to leaving argh.

Claude-Session: https://claude.ai/code/session_01K6LB3AwUmKDxaFNZ2NqPGr
`main` now returns `cw.dispatch(run, argv)` directly rather than going through the
compat shim, and `argh` is replaced by `cw>=0.1,<0.2` in `dependencies`.

Note the argv handoff: argh took it as the keyword `argv=`, cw takes it as the
second positional. `main` still returns an int and still returns 0 on success, so
`test_cli_main_entry_point`'s `main([...]) == 0` holds, and the console script's
exit code is unchanged.

Replay against the pre-migration goldens, with the --help body asserted:

    18/18 identical on CPython 3.12   (recorded from argh)
    18/18 identical on CPython 3.10   (recorded from argh)
    no --help changes
    38 passed

New `tests/test_cli_parity.py` replays the golden in the ordinary test run, so
this cannot rot: a refactor here, or a future cw release, that changes any exit
code, any byte of stdout/stderr, or the usage line fails the suite. Verified it
can fail -- renaming `snippets` to `snippet` turned it red naming exactly that
flag. (A first attempt at a negative test, forcing `convention=cw.MODERN`, did
NOT fail, and that is correct rather than alarming: for a single command whose
options are all keyword defaults, the two conventions produce identical grammar.)

`misc/cli_golden_py310.json` is recorded from *argh* too, on a throwaway 3.10 venv
over a worktree pinned at the pre-migration commit -- a migration proof on both
matrix versions, not a baseline taken after the fact. The only cases that differ
between the two recordings are the two usage errors, where CPython 3.12 stopped
listing `nargs='*'` positionals among "the following arguments are required".

Claude-Session: https://claude.ai/code/session_01K6LB3AwUmKDxaFNZ2NqPGr
argparse takes its `prog` from `basename(sys.argv[0])`, and on Windows the console
script is installed as `grub.EXE`, so every recorded usage line and error prefix
differed on the runner:

    - usage: grub     [-h] [-m METHOD] ... source [query ...]
    + usage: grub.EXE [-h] [-m METHOD] ... source [query ...]

That is a fact about packaging, not about the command line. cw 0.1.1 scrubs it
(i2mint/cw#34), which is also where the stdin fix this repo's no-query REPL case
depends on landed. Both were found by this migration, so the floor is raised rather
than left at 0.1: with `cw>=0.1` a resolver could pick 0.1.0 and this repo's own
parity test would fail on Windows for a reason nobody could act on.

Claude-Session: https://claude.ai/code/session_01K6LB3AwUmKDxaFNZ2NqPGr
@thorwhalen
thorwhalen merged commit 07d3388 into master Aug 30, 2026
14 of 18 checks passed
@thorwhalen
thorwhalen deleted the migrate-to-cw branch August 30, 2026 22:35
Sign up for free to 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