From 5ffab3accfd5cccf9e06ec8db492fedf85fe60c6 Mon Sep 17 00:00:00 2001 From: Torben Nehmer Date: Sat, 26 Sep 2026 07:57:01 +0200 Subject: [PATCH] tools: usage lines name wikitool; -h/--help acceptance checks become tests (#121) Files changed: - CHANGES.md - VERSION - tools/chemenu/cli.py - tools/chemenu/tests/test_cli.py --- CHANGES.md | 13 +++++- VERSION | 2 +- tools/chemenu/cli.py | 9 +++- tools/chemenu/tests/test_cli.py | 76 +++++++++++++++++++++++++++++++-- 4 files changed, 93 insertions(+), 7 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 32e3422..74d5156 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -59,7 +59,7 @@ concern - readable here, never shipped as something to parse. --- -## 7.1.0-beta.4 - 2026-09-25 - wikitool: one data record per command - `-h`, index and CONTRACT.md render from cli_contract (Gitea #121 Phase 1) +## 7.1.0-beta.5 - 2026-09-26 - wikitool: usage lines name wikitool, and the -h acceptance checks become tests **Author:** Torben Nehmer @@ -73,6 +73,7 @@ concern - readable here, never shipped as something to parse. **Low impact** - version bump no longer points at version release in its output - stack-close: wait for CI through the authenticated Gitea connection, with timings and a give-up point +- wikitool: usage lines name wikitool, and the -h acceptance checks become tests ### CalDAV task-tracker provider (Nextcloud Tasks, iOS Reminders); review reports unknown-value findings instead of skipping them @@ -151,6 +152,16 @@ part of this change: this pass moved the existing table cells' text into records without editing it beyond the one documented fix (the `--dry-run` synopsis gap above) and the docstring/option-text rewording needed to stop citing issue numbers in rendered help. +### wikitool: usage lines name wikitool, and the -h acceptance checks become tests + +Under `tools/wikitool` - which runs `python -m chemenu.cli` - every usage line and "Try ... -h" +hint named `python -m chemenu.cli`, a command nobody should copy; the CLI now passes +`prog_name="wikitool"` explicitly. The properties the previous change promised for help output - +byte-identical with and without a TTY, `-h` and `--help` identical for every command, no Rich +frame characters in help or in a usage error, the top-level index listing at least the seven +non-idempotent commands - had only been checked by hand; they are now pinned in +`test_cli.py`. + --- ## 7.0.0 - 2026-09-22 - Task-Tracker-Anbindung: Vorhaben als Seitenart, Verpflichtungsschicht, Weekly Review als Read-Time-Join diff --git a/VERSION b/VERSION index 454f1bb..6f5da9d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.1.0-beta.4 +7.1.0-beta.5 diff --git a/tools/chemenu/cli.py b/tools/chemenu/cli.py index 141e6b6..5fc48b9 100644 --- a/tools/chemenu/cli.py +++ b/tools/chemenu/cli.py @@ -142,6 +142,11 @@ def _pacify_real_fd(stream) -> None: # isn't a file) - nothing to redirect, same as the OSError case. pass +# What usage lines and "Try '... -h'" hints name. Without it Click takes +# argv[0], which under `tools/wikitool` (`python -m chemenu.cli`) printed +# `python -m chemenu.cli` - a command nobody should copy. +PROG_NAME = "wikitool" + app = typer.Typer( help="wikitool - deterministic operations for Chemenu (see AGENTS.md).", no_args_is_help=True, @@ -300,7 +305,7 @@ def main() -> None: return if is_help: sys.argv = [sys.argv[0], *[a for a in argv if a != "--override-budget"]] - app() + app(prog_name=PROG_NAME) def _run_traced(command: str, args: list[str], charged: bool = False) -> None: @@ -322,7 +327,7 @@ def _run_traced(command: str, args: list[str], charged: bool = False) -> None: stderr_wrap = _BrokenPipeSwallow(real_stderr) sys.stdout, sys.stderr = stdout_wrap, stderr_wrap try: - app() + app(prog_name=PROG_NAME) except SystemExit as exc: code = exc.code exit_code = code if isinstance(code, int) else (0 if code is None else 1) diff --git a/tools/chemenu/tests/test_cli.py b/tools/chemenu/tests/test_cli.py index c4a38f5..dab4d76 100644 --- a/tools/chemenu/tests/test_cli.py +++ b/tools/chemenu/tests/test_cli.py @@ -114,7 +114,7 @@ def test_a_write_cut_off_by_a_closed_pipe_is_not_recorded_as_an_error(monkeypatc stub = _FailingStream(fail_on=2) # first write succeeds, second hits EPIPE monkeypatch.setattr(sys, "stdout", stub) - def fake_app(): + def fake_app(**_kwargs): sys.stdout.write("line one\n") sys.stdout.write("line two\n") # truncated here, like `| head -1` raise SystemExit(0) @@ -137,7 +137,7 @@ def test_a_real_failure_is_still_recorded_as_one(monkeypatch, tmp_path): monkeypatch.setenv("WIKI_TRACE_DIR", str(tmp_path)) monkeypatch.setenv("WIKITOOL_SESSION_ID", "real-failure-unit") - def fake_app(): + def fake_app(**_kwargs): raise SystemExit(1) monkeypatch.setattr(cli, "app", fake_app) @@ -157,7 +157,7 @@ def test_an_ordinary_call_restores_the_real_streams_afterwards(monkeypatch, tmp_ monkeypatch.setenv("WIKITOOL_SESSION_ID", "restore-unit") real_stdout, real_stderr = sys.stdout, sys.stderr - def fake_app(): + def fake_app(**_kwargs): raise SystemExit(0) monkeypatch.setattr(cli, "app", fake_app) @@ -165,3 +165,73 @@ def test_an_ordinary_call_restores_the_real_streams_afterwards(monkeypatch, tmp_ cli._run_traced("lint", []) assert sys.stdout is real_stdout assert sys.stderr is real_stderr + + +# --- help rendering from cli_contract (Gitea #121 B5) --- + +import io # noqa: E402 + +from chemenu import cli_contract # noqa: E402 + +FRAME_CHARS = set("│╭╰─╯╮┃┏┗━┓┛") +SECTION_ORDER = ("NAME", "SYNOPSIS", "PROPERTIES", "OPTIONS", "EXIT STATUS", "ON FAILURE", "NOTES") + + +def _run(monkeypatch, args, tty=False): + """Drive the real app the way `main()` does, capturing both streams. + `tty=True` makes stdout claim to be a terminal - what a `wikitool` run in + an interactive shell sees, as opposed to an agent's pipe.""" + out, err = io.StringIO(), io.StringIO() + if tty: + out.isatty = lambda: True + monkeypatch.setattr(sys, "stdout", out) + monkeypatch.setattr(sys, "stderr", err) + monkeypatch.setattr(sys, "argv", ["whatever-argv0-is", *args]) + with pytest.raises(SystemExit): + cli.app(prog_name=cli.PROG_NAME) + return out.getvalue(), err.getvalue() + + +def test_publish_help_is_byte_identical_with_and_without_a_tty(monkeypatch): + piped, _ = _run(monkeypatch, ["publish", "-h"]) + terminal, _ = _run(monkeypatch, ["publish", "-h"], tty=True) + assert piped == terminal + + +def test_publish_help_carries_the_record_in_man_page_order(monkeypatch): + text, _ = _run(monkeypatch, ["publish", "-h"]) + assert text.startswith("NAME") + positions = [text.index(section) for section in SECTION_ORDER] + assert positions == sorted(positions) + exit_block = text[text.index("EXIT STATUS"):text.index("ON FAILURE")] + assert "\n 1 " in exit_block + assert "\n 42 " in exit_block + + +def test_every_command_renders_the_same_for_h_and_help_without_frames(monkeypatch): + for path in sorted(cli_contract.all_records()): + short, _ = _run(monkeypatch, [*path.split(" "), "-h"]) + long, _ = _run(monkeypatch, [*path.split(" "), "--help"]) + assert short == long, path + assert short.startswith("NAME"), path + assert not FRAME_CHARS & set(short), path + + +def test_top_level_help_is_the_index_without_frames(monkeypatch): + text, _ = _run(monkeypatch, ["-h"]) + assert text.startswith("Usage: wikitool") + assert not FRAME_CHARS & set(text) + non_idempotent = { + line.split(" ")[0].strip() for line in text.splitlines() if " non-idempotent " in line + } + assert { + "new", "log append", "publish", "upstream merge", + "version bump", "version release", "migrate done", + } <= non_idempotent + + +def test_a_usage_error_is_unframed_and_names_wikitool(monkeypatch): + out, err = _run(monkeypatch, ["publish", "--bogus"]) + text = out + err + assert "Usage: wikitool publish" in text + assert not FRAME_CHARS & set(text)