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
This commit is contained in:
1 parent
b79f083cc1
commit
5ffab3accf
4 files changed
+93
-7
No files matched your search
+12
-1
@@ -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
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
@@ -73,6 +73,7 @@ concern - readable here, never shipped as something to parse.
|
|||||||
**Low impact**
|
**Low impact**
|
||||||
- version bump no longer points at version release in its output
|
- 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
|
- 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
|
||||||
<!-- /wikitool:bumps -->
|
<!-- /wikitool:bumps -->
|
||||||
|
|
||||||
### CalDAV task-tracker provider (Nextcloud Tasks, iOS Reminders); review reports unknown-value findings instead of skipping them
|
### 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
|
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.
|
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
|
## 7.0.0 - 2026-09-22 - Task-Tracker-Anbindung: Vorhaben als Seitenart, Verpflichtungsschicht, Weekly Review als Read-Time-Join
|
||||||
|
|||||||
@@ -142,6 +142,11 @@ def _pacify_real_fd(stream) -> None:
|
|||||||
# isn't a file) - nothing to redirect, same as the OSError case.
|
# isn't a file) - nothing to redirect, same as the OSError case.
|
||||||
pass
|
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(
|
app = typer.Typer(
|
||||||
help="wikitool - deterministic operations for Chemenu (see AGENTS.md).",
|
help="wikitool - deterministic operations for Chemenu (see AGENTS.md).",
|
||||||
no_args_is_help=True,
|
no_args_is_help=True,
|
||||||
@@ -300,7 +305,7 @@ def main() -> None:
|
|||||||
return
|
return
|
||||||
if is_help:
|
if is_help:
|
||||||
sys.argv = [sys.argv[0], *[a for a in argv if a != "--override-budget"]]
|
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:
|
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)
|
stderr_wrap = _BrokenPipeSwallow(real_stderr)
|
||||||
sys.stdout, sys.stderr = stdout_wrap, stderr_wrap
|
sys.stdout, sys.stderr = stdout_wrap, stderr_wrap
|
||||||
try:
|
try:
|
||||||
app()
|
app(prog_name=PROG_NAME)
|
||||||
except SystemExit as exc:
|
except SystemExit as exc:
|
||||||
code = exc.code
|
code = exc.code
|
||||||
exit_code = code if isinstance(code, int) else (0 if code is None else 1)
|
exit_code = code if isinstance(code, int) else (0 if code is None else 1)
|
||||||
|
|||||||
@@ -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
|
stub = _FailingStream(fail_on=2) # first write succeeds, second hits EPIPE
|
||||||
monkeypatch.setattr(sys, "stdout", stub)
|
monkeypatch.setattr(sys, "stdout", stub)
|
||||||
|
|
||||||
def fake_app():
|
def fake_app(**_kwargs):
|
||||||
sys.stdout.write("line one\n")
|
sys.stdout.write("line one\n")
|
||||||
sys.stdout.write("line two\n") # truncated here, like `| head -1`
|
sys.stdout.write("line two\n") # truncated here, like `| head -1`
|
||||||
raise SystemExit(0)
|
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("WIKI_TRACE_DIR", str(tmp_path))
|
||||||
monkeypatch.setenv("WIKITOOL_SESSION_ID", "real-failure-unit")
|
monkeypatch.setenv("WIKITOOL_SESSION_ID", "real-failure-unit")
|
||||||
|
|
||||||
def fake_app():
|
def fake_app(**_kwargs):
|
||||||
raise SystemExit(1)
|
raise SystemExit(1)
|
||||||
|
|
||||||
monkeypatch.setattr(cli, "app", fake_app)
|
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")
|
monkeypatch.setenv("WIKITOOL_SESSION_ID", "restore-unit")
|
||||||
real_stdout, real_stderr = sys.stdout, sys.stderr
|
real_stdout, real_stderr = sys.stdout, sys.stderr
|
||||||
|
|
||||||
def fake_app():
|
def fake_app(**_kwargs):
|
||||||
raise SystemExit(0)
|
raise SystemExit(0)
|
||||||
|
|
||||||
monkeypatch.setattr(cli, "app", fake_app)
|
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", [])
|
cli._run_traced("lint", [])
|
||||||
assert sys.stdout is real_stdout
|
assert sys.stdout is real_stdout
|
||||||
assert sys.stderr is real_stderr
|
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)
|
||||||
Reference in new issue
Block a user