diff --git a/grub/cli.py b/grub/cli.py index c39affd..a951a83 100644 --- a/grub/cli.py +++ b/grub/cli.py @@ -13,7 +13,7 @@ import sys -import argh +import cw from grub.searcher import Searcher @@ -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 diff --git a/misc/README.md b/misc/README.md new file mode 100644 index 0000000..b37247e --- /dev/null +++ b/misc/README.md @@ -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.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. diff --git a/misc/cli_cases.txt b/misc/cli_cases.txt new file mode 100644 index 0000000..761e22c --- /dev/null +++ b/misc/cli_cases.txt @@ -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 diff --git a/misc/cli_golden_py310.json b/misc/cli_golden_py310.json new file mode 100644 index 0000000..f566530 --- /dev/null +++ b/misc/cli_golden_py310.json @@ -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": " -- 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" + } +} diff --git a/misc/cli_golden_py312.json b/misc/cli_golden_py312.json new file mode 100644 index 0000000..466b2cb --- /dev/null +++ b/misc/cli_golden_py312.json @@ -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": " -- 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\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" + }, + { + "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\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" + }, + { + "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 b69fd73, pre-cw-migration, CPython 3.12", + "prog": [ + "grub" + ], + "recorded_with": { + "implementation": "CPython", + "platform": "Darwin", + "python": "3.12.12" + } +} diff --git a/pyproject.toml b/pyproject.toml index fab42e5..b8061ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ dependencies = [ "py2store", "scikit-learn", "numpy", - "argh", + "cw>=0.1.1,<0.2", ] [project.license] diff --git a/tests/fixtures/corpus/coffee.txt b/tests/fixtures/corpus/coffee.txt new file mode 100644 index 0000000..b9a5f83 --- /dev/null +++ b/tests/fixtures/corpus/coffee.txt @@ -0,0 +1 @@ +Coffee is a hot drink brewed from roasted ground coffee beans. diff --git a/tests/fixtures/corpus/garden.txt b/tests/fixtures/corpus/garden.txt new file mode 100644 index 0000000..9df9557 --- /dev/null +++ b/tests/fixtures/corpus/garden.txt @@ -0,0 +1 @@ +A garden grows vegetables flowers and herbs in rich healthy soil. diff --git a/tests/fixtures/corpus/guitar.txt b/tests/fixtures/corpus/guitar.txt new file mode 100644 index 0000000..dcbab83 --- /dev/null +++ b/tests/fixtures/corpus/guitar.txt @@ -0,0 +1 @@ +A guitar is a musical instrument with six strings played by hand. diff --git a/tests/fixtures/corpus/python.txt b/tests/fixtures/corpus/python.txt new file mode 100644 index 0000000..a84feae --- /dev/null +++ b/tests/fixtures/corpus/python.txt @@ -0,0 +1 @@ +Python is a popular high level programming language for general use. diff --git a/tests/test_cli_parity.py b/tests/test_cli_parity.py new file mode 100644 index 0000000..6acb840 --- /dev/null +++ b/tests/test_cli_parity.py @@ -0,0 +1,68 @@ +"""Assert grub's command line has not moved since it was recorded. + +``misc/cli_golden_py3XX.json`` were recorded from the **argh-based** CLI, before the +migration to ``cw`` and before any source edit; ``misc/cli_cases.txt`` is the corpus they +were recorded from, and ``misc/README.md`` explains how to read a red result and how to +re-record. This test replays them against whatever dispatcher is installed now, so neither +a refactor here nor a new ``cw`` release can change what a shell sees without a test going +red. + +Every case's exit code, stdout, stderr and normalised ``usage:`` line is asserted, and so is +the full ``--help`` body (``strict_help``). Nothing is advisory. + +One golden per CPython minor version, because ``argparse`` is stdlib and rewrites its own +text between versions: 3.12 stopped listing ``nargs='*'`` positionals among "the following +arguments are required", which is the only difference between the 3.10 and 3.12 recordings +and predates this repo's migration. An unrecorded version fails loudly rather than skipping +-- a parity test that quietly does nothing is worse than no parity test. +""" + +import shutil +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +GOLDEN_DIR = REPO_ROOT / "misc" + + +def _golden_for_this_python() -> Path: + """The golden recorded by the CPython running this test.""" + major, minor = sys.version_info[:2] + path = GOLDEN_DIR / f"cli_golden_py{major}{minor}.json" + if path.exists(): + return path + recorded = sorted(p.name for p in GOLDEN_DIR.glob("cli_golden_py*.json")) + pytest.fail( + f"no CLI golden recorded for Python {major}.{minor} (have: {', '.join(recorded)}). " + f"argparse's own wording differs between CPython versions, so each version in CI " + f"needs its own recording. See misc/README.md." + ) + + +def _console_script() -> str: + """The installed ``grub`` executable, however this environment lays it out.""" + found = shutil.which("grub") + if found: + return found + # A venv whose bin/ is not on PATH -- common under `uv run` and tox. + for name in ("grub", "grub.exe"): + candidate = Path(sys.executable).parent / name + if candidate.exists(): + return str(candidate) + pytest.fail( + "the `grub` console script is not installed, so the CLI cannot be checked. " + "Install the package (`pip install -e .`) before running this test." + ) + + +def test_cli_surface_is_unchanged(): + """Every recorded argv still produces the same exit code, stdout and stderr.""" + cw_testing = pytest.importorskip("cw.testing") + cw_testing.assert_replay( + _golden_for_this_python(), + prog=[_console_script()], + cwd=str(REPO_ROOT), + strict_help=True, + )