Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions grub/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

import sys

import argh
import cw

from grub.searcher import Searcher

Expand Down Expand Up @@ -88,9 +88,13 @@ def _repl(searcher: Searcher, *, snippets: bool) -> None:


def main(argv=None) -> int:
"""Entry point for the ``grub`` console script."""
argh.dispatch_command(run, argv=argv)
return 0
"""Entry point for the ``grub`` console script; returns its exit code.

``argv`` defaults to ``sys.argv[1:]``. :func:`cw.dispatch` takes it positionally and
reproduces the grammar this CLI has always had -- pinned by ``misc/cli_golden_py*.json``
and asserted by ``tests/test_cli_parity.py``.
"""
return cw.dispatch(run, argv)


if __name__ == "__main__": # pragma: no cover
Expand Down
51 changes: 51 additions & 0 deletions misc/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# `misc/` — the CLI parity harness

`grub`'s command line is **pinned**. `cli_cases.txt` is a corpus of argv vectors;
`cli_golden_py<major><minor>.json` records what each one does — exit code, stdout,
stderr, the normalised `usage:` line, and the full `--help` body. `tests/test_cli_parity.py`
replays the golden matching the running interpreter on every test run.

The goldens were recorded from the **argh-based** CLI, before the migration to
[`cw`](https://pypi.org/project/cw/) and before any source edit. That is what makes them
evidence rather than decoration: they describe a command line nobody had yet had the
chance to change.

## When the parity test goes red

**Read the diff before touching anything.** It tells you which of three things happened.

1. **You changed the CLI on purpose** — added a command, renamed a flag, reworded a
message. Re-record (below) and put the diff in the pull request.
2. **A dependency moved.** The only byte in the golden that a dependency can
legitimately move is a `[0.437]` similarity score, which comes from scikit-learn's
`TfidfVectorizer`. A diff confined to the bracketed numbers is a scikit-learn change;
re-record. A diff anywhere else is not.
3. **You changed the CLI by accident.** This is what the test is for. Fix the code, not
the golden.

## Re-recording

One golden per Python version in `[tool.wads.ci.testing]`, each recorded on its own
interpreter, from the repo root:

```bash
python3.12 -m cw.testing characterize grub --cases misc/cli_cases.txt \
-o misc/cli_golden_py312.json
```

`argparse` is part of the standard library and rewrites its own text between CPython
versions, which is why one golden cannot cover the matrix. Adding a version to CI means
recording a golden for it; the test fails loudly, with instructions, rather than skipping
when one is missing.

## Adding a case

Anything a user can type belongs here. Four rules, all learned the hard way:

- **No case may print an absolute path.** The golden is committed and has to replay on
someone else's machine. In particular, no case whose failure escapes as an uncaught
traceback — the traceback names every frame's file.
- **No case may write to the filesystem or reach the network.**
- **No case may need an optional extra**, so the corpus replays on a bare install.
- **Prefer output that a dependency upgrade cannot move.** `-n 1` on a search keeps the
assertion to a single score rather than four.
59 changes: 59 additions & 0 deletions misc/cli_cases.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# grub CLI characterization corpus. Replayed by tests/test_cli_parity.py;
# re-recording procedure is in misc/README.md.
#
# Recorded from the argh-based CLI *before* the migration to cw, and replayed after, so a
# dispatcher swap that changes anything a shell can see fails loudly.
#
# Rules for adding a case:
# * paths are relative to the repo root (characterize/replay run with cwd=repo root);
# * no case may print an absolute path -- the golden is committed and must replay on
# someone else's machine. That rules out any case whose failure escapes as an
# uncaught traceback: `grub SRC Q --extensions .md` over a .txt-only corpus raises
# `ValueError: empty vocabulary` out of scikit-learn, and the traceback names every
# frame's file. (It also names the dispatcher's own frames, so it could never have
# replayed across this migration anyway.)
# * no case may write to the filesystem or reach the network;
# * no case may need the optional `semantic` extra (sentence-transformers).

# -- tier 3: the whole surface. grub is one command, so this is all of its grammar --
--help

# -- tier 1: the headline behaviour. With no query, grub drops into an interactive
# prompt; recorded with stdin closed, so it prints the banner and one prompt and
# exits 0 on EOF. This case is why cw.testing had to pin stdin (i2mint/cw#34):
# inheriting the recorder's made it record a timeout at a terminal and an EOF in CI.
tests/fixtures/corpus

# -- tier 1: a search. `-n 1` keeps the output to a single result line, so exactly one
# similarity score is asserted.
#
# NOTE: that score comes from scikit-learn's TfidfVectorizer, and is the only byte in
# this golden a dependency upgrade could legitimately move. A diff confined to the
# bracketed number is a scikit-learn change -- re-record. A diff anywhere else is a
# CLI change; read it before you re-record anything.
tests/fixtures/corpus "musical instrument" -n 1
tests/fixtures/corpus "musical instrument" --snippets -n 1
tests/fixtures/corpus "hot drink" --method tfidf -n 1
tests/fixtures/corpus "hot drink" -m tfidf -n 1

# -- tier 1: a multi-word query as separate argv items, which is what *query catches --
tests/fixtures/corpus python programming language -n 1

# -- tier 1: flags that reach the Searcher's constructor rather than its search --
tests/fixtures/corpus "musical instrument" --extensions .txt -n 1
tests/fixtures/corpus "musical instrument" -e .txt -n 1
tests/fixtures/corpus "musical instrument" --chunk 40 -n 2
tests/fixtures/corpus "musical instrument" -c 40 -n 2

# -- tier 1: --n is the long spelling argh derives for a one-character parameter --
tests/fixtures/corpus "musical instrument" --n 1

# -- tier 1: the error path grub handles itself. Exits 1, not argparse's 2 --
./no/such/place query

# -- tier 1: usage errors. These must keep argparse's exit code 2 --
[]
--nosuchflag
-n notanint
--n notanint
--method
249 changes: 249 additions & 0 deletions misc/cli_golden_py310.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
{
"cases": [
{
"argv": [
"--help"
],
"returncode": 0,
"stderr": "",
"stdout": "usage: grub [-h] [-m METHOD] [-n N] [--snippets] [--semantic] [--hybrid] [-c CHUNK]\n [-e EXTENSIONS]\n source [query ...]\n\nSearch ``source``; with no query, drop into an interactive prompt.\n\n:param source: folder, glob, ``dir/{}.ext`` format, module path, or URL.\n:param query: the search query (omit for an interactive session).\n:param method: ``tfidf``, ``semantic``, or ``hybrid``.\n:param n: number of results to show.\n:param snippets: also show the matching snippet for each result.\n:param semantic: shortcut for ``--method semantic``.\n:param hybrid: shortcut for ``--method hybrid``.\n:param chunk: split documents into windows of this many characters.\n:param extensions: comma-separated file extensions to restrict to,\n e.g. ``.py,.md``.\n\npositional arguments:\n source -\n query -\n\noptions:\n -h, --help show this help message and exit\n -m METHOD, --method METHOD\n 'tfidf'\n -n N, --n N 10\n --snippets False\n --semantic False\n --hybrid False\n -c CHUNK, --chunk CHUNK\n 0\n -e EXTENSIONS, --extensions EXTENSIONS\n ''\n",
"tier": 3,
"usage": "usage: grub [-h] [-m METHOD] [-n N] [--snippets] [--semantic] [--hybrid] [-c CHUNK] [-e EXTENSIONS] source [query ...]"
},
{
"argv": [
"tests/fixtures/corpus"
],
"returncode": 0,
"stderr": "",
"stdout": "<Searcher: tfidf index over 4 documents> -- type a query (blank line or Ctrl-D to quit)\ngrub> \n",
"tier": 1,
"usage": ""
},
{
"argv": [
"tests/fixtures/corpus",
"musical instrument",
"-n",
"1"
],
"returncode": 0,
"stderr": "",
"stdout": "1 results for 'musical instrument':\n 1. [0.437] guitar.txt\n",
"tier": 1,
"usage": ""
},
{
"argv": [
"tests/fixtures/corpus",
"musical instrument",
"--snippets",
"-n",
"1"
],
"returncode": 0,
"stderr": "",
"stdout": "1 results for 'musical instrument':\n 1. [0.437] guitar.txt\n A guitar is a musical instrument with six strings played by hand.\n",
"tier": 1,
"usage": ""
},
{
"argv": [
"tests/fixtures/corpus",
"hot drink",
"--method",
"tfidf",
"-n",
"1"
],
"returncode": 0,
"stderr": "",
"stdout": "1 results for 'hot drink':\n 1. [0.414] coffee.txt\n",
"tier": 1,
"usage": ""
},
{
"argv": [
"tests/fixtures/corpus",
"hot drink",
"-m",
"tfidf",
"-n",
"1"
],
"returncode": 0,
"stderr": "",
"stdout": "1 results for 'hot drink':\n 1. [0.414] coffee.txt\n",
"tier": 1,
"usage": ""
},
{
"argv": [
"tests/fixtures/corpus",
"python",
"programming",
"language",
"-n",
"1"
],
"returncode": 0,
"stderr": "",
"stdout": "1 results for 'python programming language':\n 1. [0.557] python.txt\n",
"tier": 1,
"usage": ""
},
{
"argv": [
"tests/fixtures/corpus",
"musical instrument",
"--extensions",
".txt",
"-n",
"1"
],
"returncode": 0,
"stderr": "",
"stdout": "1 results for 'musical instrument':\n 1. [0.437] guitar.txt\n",
"tier": 1,
"usage": ""
},
{
"argv": [
"tests/fixtures/corpus",
"musical instrument",
"-e",
".txt",
"-n",
"1"
],
"returncode": 0,
"stderr": "",
"stdout": "1 results for 'musical instrument':\n 1. [0.437] guitar.txt\n",
"tier": 1,
"usage": ""
},
{
"argv": [
"tests/fixtures/corpus",
"musical instrument",
"--chunk",
"40",
"-n",
"2"
],
"returncode": 0,
"stderr": "",
"stdout": "2 results for 'musical instrument':\n 1. [0.530] guitar.txt#0\n 2. [0.000] coffee.txt#0\n",
"tier": 1,
"usage": ""
},
{
"argv": [
"tests/fixtures/corpus",
"musical instrument",
"-c",
"40",
"-n",
"2"
],
"returncode": 0,
"stderr": "",
"stdout": "2 results for 'musical instrument':\n 1. [0.530] guitar.txt#0\n 2. [0.000] coffee.txt#0\n",
"tier": 1,
"usage": ""
},
{
"argv": [
"tests/fixtures/corpus",
"musical instrument",
"--n",
"1"
],
"returncode": 0,
"stderr": "",
"stdout": "1 results for 'musical instrument':\n 1. [0.437] guitar.txt\n",
"tier": 1,
"usage": ""
},
{
"argv": [
"./no/such/place",
"query"
],
"returncode": 1,
"stderr": "grub: No such file, folder, or glob: './no/such/place'. Pass an existing path, a glob like 'docs/*.md', a 'dir/{}.py' format, a module, or a dict.\n",
"stdout": "",
"tier": 1,
"usage": ""
},
{
"argv": [],
"returncode": 2,
"stderr": "usage: grub [-h] [-m METHOD] [-n N] [--snippets] [--semantic] [--hybrid] [-c CHUNK]\n [-e EXTENSIONS]\n source [query ...]\ngrub: error: the following arguments are required: source, query\n",
"stdout": "",
"tier": 1,
"usage": "usage: grub [-h] [-m METHOD] [-n N] [--snippets] [--semantic] [--hybrid] [-c CHUNK] [-e EXTENSIONS] source [query ...] grub: error: the following arguments are required: source, query"
},
{
"argv": [
"--nosuchflag"
],
"returncode": 2,
"stderr": "usage: grub [-h] [-m METHOD] [-n N] [--snippets] [--semantic] [--hybrid] [-c CHUNK]\n [-e EXTENSIONS]\n source [query ...]\ngrub: error: the following arguments are required: source, query\n",
"stdout": "",
"tier": 1,
"usage": "usage: grub [-h] [-m METHOD] [-n N] [--snippets] [--semantic] [--hybrid] [-c CHUNK] [-e EXTENSIONS] source [query ...] grub: error: the following arguments are required: source, query"
},
{
"argv": [
"-n",
"notanint"
],
"returncode": 2,
"stderr": "usage: grub [-h] [-m METHOD] [-n N] [--snippets] [--semantic] [--hybrid] [-c CHUNK]\n [-e EXTENSIONS]\n source [query ...]\ngrub: error: argument -n/--n: invalid int value: 'notanint'\n",
"stdout": "",
"tier": 1,
"usage": "usage: grub [-h] [-m METHOD] [-n N] [--snippets] [--semantic] [--hybrid] [-c CHUNK] [-e EXTENSIONS] source [query ...] grub: error: argument -n/--n: invalid int value: 'notanint'"
},
{
"argv": [
"--n",
"notanint"
],
"returncode": 2,
"stderr": "usage: grub [-h] [-m METHOD] [-n N] [--snippets] [--semantic] [--hybrid] [-c CHUNK]\n [-e EXTENSIONS]\n source [query ...]\ngrub: error: argument -n/--n: invalid int value: 'notanint'\n",
"stdout": "",
"tier": 1,
"usage": "usage: grub [-h] [-m METHOD] [-n N] [--snippets] [--semantic] [--hybrid] [-c CHUNK] [-e EXTENSIONS] source [query ...] grub: error: argument -n/--n: invalid int value: 'notanint'"
},
{
"argv": [
"--method"
],
"returncode": 2,
"stderr": "usage: grub [-h] [-m METHOD] [-n N] [--snippets] [--semantic] [--hybrid] [-c CHUNK]\n [-e EXTENSIONS]\n source [query ...]\ngrub: error: argument -m/--method: expected one argument\n",
"stdout": "",
"tier": 1,
"usage": "usage: grub [-h] [-m METHOD] [-n N] [--snippets] [--semantic] [--hybrid] [-c CHUNK] [-e EXTENSIONS] source [query ...] grub: error: argument -m/--method: expected one argument"
}
],
"cw_golden": 1,
"env": {
"COLUMNS": "100",
"NO_COLOR": "1",
"PYTHONHASHSEED": "0",
"PYTHONIOENCODING": "utf-8",
"PYTHONUTF8": "1",
"TERM": "dumb"
},
"newlines": "lf",
"note": "argh 0.31.3, grub 6956353, pre-cw-migration, CPython 3.10",
"prog": [
"grub"
],
"recorded_with": {
"implementation": "CPython",
"platform": "Darwin",
"python": "3.10.13"
}
}
Loading
Loading