tools: fail() prints ON FAILURE lines on stderr after ERROR (#143)
CI / verify (push) Successful in 1m15s
Release / release (push) Successful in 37s

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2

Files changed:
- AGENTS.md
- CHANGES.md
- VERSION
- tools/CONTRACT.md
- tools/chemenu/cli.py
- tools/chemenu/cli_contract.py
- tools/chemenu/commands/_util.py
- tools/chemenu/tests/test_cli.py
- tools/chemenu/tests/test_cli_contract.py
- tools/chemenu/tests/test_util.py
This commit is contained in:
torben committed 2026-09-26 14:53:51 +02:00
1 parent 5fe6003929
commit 63f566ff05
10 files changed
+403 -19

No files matched your search

+5
View File
@@ -74,6 +74,11 @@ tools/wikitool <command> -h
way, for a human or an agent. Bare `tools/wikitool -h` prints the index below plus a pointer back
to this form.
A call that ends through `_util.fail()` (exit 1) prints its `ERROR` line to stdout as before, then
its record's ON FAILURE reaction(s) to stderr, in the same `<cause> -> <reaction>` form `-h` prints
- so the reaction is in front of the caller without a second `-h` call. A record with no exit-1
cause of its own falls back to a bare `see: wikitool <cmd> -h` pointer.
## Commands
<!-- wikitool:commands -->
+1 -16
View File
@@ -189,21 +189,6 @@ app.command("sync")(git_publish.sync_command)
app.command("doctor")(doctor.doctor_command)
def _contract_path(ctx) -> str:
"""The dotted `cli_contract` path for `ctx`'s command (`"xref add"`,
`"new"`), built by walking up the Click context chain and collecting each
level's own `info_name` - never from `ctx.command_path`, which is
prefixed with whatever this process's argv[0] happened to be (`wikitool`,
`cli.py`, `-c` under a `python -c` snippet, ...) and would make path
resolution depend on how the CLI was invoked."""
parts: list[str] = []
node = ctx
while node.parent is not None:
parts.append(node.info_name)
node = node.parent
return " ".join(reversed(parts))
def _render_options_text(command, ctx) -> str:
"""Click's own Arguments/Options sections, plain-formatted, for splicing
into a `cli_contract` record's OPTIONS section. A fixed width (not the
@@ -264,7 +249,7 @@ try:
if ctx.parent is None:
formatter.write(_render_root_help())
return
record = cli_contract.get(_contract_path(ctx))
record = cli_contract.get(cli_contract.path_of(ctx))
if record is None:
_original_format_help(self, ctx, formatter)
return
+49
View File
@@ -77,6 +77,12 @@ class Failure:
that is not one (an unreachable remote reported and skipped). An empty
`reaction` renders the EXIT STATUS line only.
A `code: 1` entry's `reaction` is not only read from `-h`: `_util.fail()`
prints it on stderr, right after the `ERROR` line, the moment the command
actually fails (`render_failure_hint`). Write it to stand on its own at
that moment too, not only next to `cause` in a document someone is
reading end to end.
`label` names the usage form a cause belongs to (`"new project"`) and is
rendered as a `<label>: ` prefix on both lines; empty when the command
has one form, or when the cause already says it."""
@@ -191,6 +197,24 @@ def get(path: str) -> Optional[CommandRecord]:
return _REGISTRY.get(path)
def path_of(ctx) -> str:
"""The dotted `cli_contract` path for a Click context (`"xref add"`,
`"new"`), built by walking up the context chain and collecting each
level's own `info_name` - never from `ctx.command_path`, which is
prefixed with whatever this process's argv[0] happened to be (`wikitool`,
`cli.py`, `-c` under a `python -c` snippet, ...) and would make path
resolution depend on how the CLI was invoked.
Shared by `cli.py`'s help rendering and `_util.fail()`'s runtime hint -
the same lookup, at two different moments in a command's life."""
parts: list[str] = []
node = ctx
while node.parent is not None:
parts.append(node.info_name)
node = node.parent
return " ".join(reversed(parts))
def all_records() -> dict[str, CommandRecord]:
"""A copy of the registry, keyed by command path."""
return dict(_REGISTRY)
@@ -346,6 +370,31 @@ def render_on_failure_lines(rec: CommandRecord) -> list[str]:
]
def render_failure_hint(rec: CommandRecord) -> str:
"""The runtime hint `_util.fail()` prints on stderr right after the
`ERROR` line it just wrote to stdout (Gitea #143): the record's exit-1
causes that carry a reaction, in the same `<cause> -> <reaction>` form as
`-h`'s ON FAILURE section - so the reaction is in front of the caller
without a second `wikitool <path> -h` call.
Unlike `render_on_failure_lines`, this only ever shows a `code: 1` cause -
a `code: 42` cause is the gate's own re-run line, already printed by the
gate itself, and a `code: 0` cause is not a failure at all. A record with
no such cause - a command whose failures are all cleared by a gate, or a
record that has not caught up with a `fail()` call the code added later
(Gitea #146) - falls back to a bare pointer instead of printing nothing."""
lines = [
_labelled(failure, f"{failure.cause} -> {failure.reaction}")
for failure in sorted(rec.failures, key=lambda f: f.code)
if failure.code == 1 and failure.reaction
]
if not lines:
return f"see: wikitool {rec.path} -h"
header = f"ON FAILURE (wikitool {rec.path} -h):"
body = "\n".join(f" {line}" for line in lines)
return f"{header}\n{body}"
def render_text(rec: CommandRecord, options_text: str = "") -> str:
"""The full plain-text record, in NAME/SYNOPSIS/.../SEE ALSO order, for
`wikitool <path> -h`. `options_text` is Click's own rendered Options
+39
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import re
import sys
from datetime import date
from pathlib import Path
from typing import Any, Dict, Optional
@@ -41,12 +42,50 @@ def declined() -> bool:
def fail(msg: str) -> None:
"""Print `ERROR <msg>` and leave through `typer.Exit(1)`.
Followed by the command's ON FAILURE hint on stderr - see
`_print_failure_hint`."""
global _declined
_declined = True
console.print(f"[bold red]ERROR[/bold red] {msg}")
_print_failure_hint()
raise typer.Exit(code=1)
def _print_failure_hint() -> None:
"""Print the running command's ON FAILURE hint to stderr, right after
the `ERROR` line `fail()` just wrote to stdout (Gitea #143): an agent
sees the reaction without a second `wikitool <path> -h` call. Plain text,
not through `console` - a reaction can contain a literal `[--flag]`,
which Rich would otherwise try to read as markup.
Reaches into `typer._click`, the same private, unpinned module `cli.py`
patches for its help rendering (see its own comment on why this is safe
to do). A failure here - no Click context yet (`fail()` called from
outside a command, see Gitea #147), a future typer that restructures the
module, a record this path cannot resolve - must not turn a validation
error into a crash: it is swallowed, and the call prints only the
`ERROR` line, exactly as it did before this hint existed.
"""
console.file.flush()
try:
from typer._click.globals import get_current_context
from chemenu import cli_contract
ctx = get_current_context(silent=True)
if ctx is None:
return
record = cli_contract.get(cli_contract.path_of(ctx))
if record is None:
return
sys.stderr.write(cli_contract.render_failure_hint(record) + "\n")
sys.stderr.flush()
except Exception:
pass
def needs_clearance(msg: str) -> None:
"""Refuse with EXIT_NEEDS_CLEARANCE. The message is written to be shown to
a human verbatim - it is the whole user-facing artifact of this gate."""
+85
View File
@@ -13,11 +13,16 @@ part being routed around.
"""
import errno
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
import typer
from chemenu import cli
from chemenu.commands import _util, run_budget
def read_lines(path):
@@ -152,6 +157,30 @@ def test_a_real_failure_is_still_recorded_as_one(monkeypatch, tmp_path):
assert "stdout_truncated" not in call["attrs"]
def test_declined_call_is_still_refunded_after_the_new_stderr_hint(monkeypatch, tmp_path):
"""`_util.fail()` still sets `_declined` before anything else - the
Budget Gate's refund path in `_run_traced` is unaffected by the new
ON FAILURE hint on stderr (Gitea #143)."""
monkeypatch.setenv("WIKI_TRACE_DIR", str(tmp_path))
monkeypatch.setenv("WIKITOOL_SESSION_ID", "declined-refund-unit")
refunded = []
monkeypatch.setattr(run_budget, "refund", lambda: refunded.append(True))
def fake_app(**_kwargs):
try:
_util.fail("Widget not found.")
except typer.Exit as exc:
raise SystemExit(exc.exit_code)
monkeypatch.setattr(cli, "app", fake_app)
with pytest.raises(SystemExit) as exc:
cli._run_traced("touch", ["--page", "x"], charged=True)
assert exc.value.code == 1
assert refunded == [True]
def test_an_ordinary_call_restores_the_real_streams_afterwards(monkeypatch, tmp_path):
monkeypatch.setenv("WIKI_TRACE_DIR", str(tmp_path))
monkeypatch.setenv("WIKITOOL_SESSION_ID", "restore-unit")
@@ -254,3 +283,59 @@ def test_every_gated_record_shows_its_re_run_after_exit_42():
and not any(flag in example for example in rec.examples for flag in clearing_flags)
]
assert missing == []
# --- fail()'s ON FAILURE hint, through two real commands (Gitea #143) ---
def test_touch_fail_prints_its_records_on_failure_hint(kb_dir, monkeypatch):
"""`touch --page <missing>` fails via `_util.fail()` before any write.
stdout keeps carrying only the `ERROR` line; the hint on stderr is
exactly what `render_failure_hint` renders for `touch`'s own record."""
out, err = _run(monkeypatch, ["touch", "--page", "__no_such_page__"])
assert out.startswith("ERROR ")
assert "ON FAILURE" not in out
assert err == cli_contract.render_failure_hint(cli_contract.get("touch")) + "\n"
def test_xref_add_fail_prints_its_records_on_failure_hint(kb_dir, monkeypatch):
"""A second real command, in a different module, so the hint is not an
artefact of one call site."""
out, err = _run(monkeypatch, [
"xref", "add", "--a", "__no_such_page_a__", "--b", "__no_such_page_b__", "--rel", "depends-on",
])
assert out.startswith("ERROR ")
assert "ON FAILURE" not in out
assert err == cli_contract.render_failure_hint(cli_contract.get("xref add")) + "\n"
def test_stderr_hint_follows_the_error_line_when_streams_are_merged(tmp_path):
"""A subprocess-level check for the property an in-process StringIO test
cannot show: under real OS pipes merged onto one stream (`2>&1`), the
`ERROR` line is still first, because `fail()` flushes stdout before it
writes the hint to stderr.
`docs contract` against an empty `CHEMENU_ROOT` fails because
`tools/CONTRACT.md` does not exist there - no fixture kb, no write,
nothing to clean up. It is also the one real record with no exit-1 cause
of its own (Gitea #146), so this doubles as the real-command check for
the `see:` fallback."""
tools_dir = Path(__file__).resolve().parents[2]
env = dict(os.environ)
env["CHEMENU_ROOT"] = str(tmp_path)
env["WIKI_TRACE_DIR"] = str(tmp_path / "trace")
env["WIKITOOL_SESSION_ID"] = "test-143-merged-streams"
result = subprocess.run(
[sys.executable, "-m", "chemenu.cli", "docs", "contract"],
cwd=str(tools_dir),
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
timeout=30,
)
assert result.returncode == 1
lines = result.stdout.splitlines()
assert lines[0] == "ERROR tools/CONTRACT.md is missing."
assert lines[1] == "see: wikitool docs contract -h"
+70
View File
@@ -302,3 +302,73 @@ def test_failure_code_outside_0_1_42_is_refused():
def test_notes_as_one_string_is_refused():
with pytest.raises(ValueError, match="tuple of bullets"):
_fixture_record(notes="One paragraph, the phase-1 form.")
def test_render_failure_hint_shows_only_exit_1_causes_with_a_reaction():
"""Gitea #143: the runtime hint `_util.fail()` prints is narrower than
`-h`'s ON FAILURE section - a `code: 42` cause is the gate's own re-run
line (already printed by the gate), a `code: 0` cause is not a failure,
and a cause without a reaction has nothing to add."""
rec = _fixture_record(
properties=cc.Properties(
effect=cc.Effect.WRITE,
idempotent=cc.Idempotent.NO,
atomic="No",
budget=cc.Budget.COUNTED,
gates=("mass-update",),
),
failures=(
cc.Failure(cause="Mass-Update Gate: too many files", reaction="Show the output and stop", code=42),
cc.Failure(cause="Widget not found", reaction="Fix the name and retry once"),
cc.Failure(cause="Bad name", reaction="Fix it", label="frobnicate --b"),
cc.Failure(cause="Nothing to do", reaction="", code=0),
cc.Failure(cause="Deprecated flag", reaction="", code=1),
),
)
hint = cc.render_failure_hint(rec)
assert hint == (
"ON FAILURE (wikitool frobnicate -h):\n"
" Widget not found -> Fix the name and retry once\n"
" frobnicate --b: Bad name -> Fix it"
)
def test_render_failure_hint_falls_back_to_a_bare_pointer():
"""A record with no exit-1 cause that carries a reaction - `docs
contract` is the real case (Gitea #146) - gets a `see:` pointer instead
of an empty or missing hint."""
assert cc.render_failure_hint(_fixture_record(failures=())) == "see: wikitool frobnicate -h"
rec = _fixture_record(
properties=cc.Properties(
effect=cc.Effect.WRITE,
idempotent=cc.Idempotent.NO,
atomic="No",
budget=cc.Budget.COUNTED,
gates=("mass-update",),
),
failures=(cc.Failure(cause="Too many files", reaction="Show the output and stop", code=42),),
)
assert cc.render_failure_hint(rec) == "see: wikitool frobnicate -h"
class _FakeContext:
def __init__(self, info_name, parent=None):
self.info_name = info_name
self.parent = parent
def test_path_of_walks_the_context_chain_by_info_name():
root = _FakeContext(None, parent=None)
group = _FakeContext("xref", parent=root)
leaf = _FakeContext("add", parent=group)
assert cc.path_of(leaf) == "xref add"
def test_path_of_ignores_argv0():
"""Unlike `ctx.command_path`, `path_of` never sees argv[0] - a fake
root `info_name` (what `ctx.command_path` would be prefixed with) is not
walked at all, since the root context has no parent."""
root = _FakeContext("python -m chemenu.cli", parent=None)
leaf = _FakeContext("touch", parent=root)
assert cc.path_of(leaf) == "touch"
+132
View File
@@ -1,4 +1,9 @@
"""Shared CLI helpers - the list format `--set` and the xref flags both use."""
import pytest
import typer
from chemenu import cli_contract as cc
from chemenu.commands import _util
from chemenu.commands._util import parse_list
@@ -27,3 +32,130 @@ def test_escaped_and_separating_commas_mix_in_one_value():
def test_escape_survives_surrounding_whitespace():
assert parse_list(r" A\, B , C ") == ["A, B", "C"]
# --- fail(): the ON FAILURE hint printed on stderr (Gitea #143) ---
class _FakeContext:
"""Stands in for a Click context - `path_of` only ever reads these two
attributes, so a real Typer invocation is not needed to exercise it."""
def __init__(self, info_name, parent=None):
self.info_name = info_name
self.parent = parent
@pytest.fixture(autouse=True)
def _clean_registry():
"""Every test in this module gets an empty `cli_contract` registry and
leaves one behind - same pattern as `test_cli_contract.py`."""
saved = cc.all_records()
cc.reset_registry_for_tests()
try:
yield
finally:
cc.reset_registry_for_tests()
for rec in saved.values():
cc._REGISTRY[rec.path] = rec
def _register(path: str, failures: tuple) -> None:
gates = ("mass-update",) if any(f.code == 42 for f in failures) else ()
cc.record(
cc.CommandRecord(
path=path,
summary="Frobnicate the widget.",
synopsis=(cc.Variant(usage=f"{path} --widget <name>"),),
properties=cc.Properties(
effect=cc.Effect.WRITE,
idempotent=cc.Idempotent.NO,
atomic="Yes - single file write",
budget=cc.Budget.COUNTED,
gates=gates,
),
notes=("Frobnicates the named widget in place.",),
failures=failures,
)
)(lambda: None)
def test_fail_prints_the_hint_on_stderr_leaving_stdout_untouched(monkeypatch, capsys):
_register("frobnicate", (
cc.Failure(cause="Widget not found", reaction="Fix the name and retry once"),
cc.Failure(cause="Too many files", reaction="Show the output and stop", code=42),
))
ctx = _FakeContext("frobnicate", parent=_FakeContext(None))
monkeypatch.setattr("typer._click.globals.get_current_context", lambda silent=False: ctx)
with pytest.raises(typer.Exit) as exc:
_util.fail("Widget 'x' not found.")
assert exc.value.exit_code == 1
assert _util.declined() is True
out, err = capsys.readouterr()
assert out == "ERROR Widget 'x' not found.\n"
# Only the exit-1 cause with a reaction - not the exit-42 one, which is
# the gate's own re-run line and already printed by the gate itself.
assert err == (
"ON FAILURE (wikitool frobnicate -h):\n"
" Widget not found -> Fix the name and retry once\n"
)
def test_fail_falls_back_to_a_bare_pointer_with_no_exit_1_cause(monkeypatch, capsys):
_register("frobnicate", ())
ctx = _FakeContext("frobnicate", parent=_FakeContext(None))
monkeypatch.setattr("typer._click.globals.get_current_context", lambda silent=False: ctx)
with pytest.raises(typer.Exit):
_util.fail("Nothing to frobnicate.")
out, err = capsys.readouterr()
assert out == "ERROR Nothing to frobnicate.\n"
assert err == "see: wikitool frobnicate -h\n"
def test_fail_without_a_click_context_prints_no_hint(monkeypatch, capsys):
"""`fail()` can be reached from outside a Typer command dispatch (the
Budget Gate / Loop-Breaker in `cli.main()`, Gitea #147) - no context
means no hint, not a crash."""
monkeypatch.setattr("typer._click.globals.get_current_context", lambda silent=False: None)
with pytest.raises(typer.Exit):
_util.fail("Something went wrong.")
out, err = capsys.readouterr()
assert out == "ERROR Something went wrong.\n"
assert err == ""
def test_fail_with_no_matching_record_prints_no_hint(monkeypatch, capsys):
ctx = _FakeContext("unregistered-command", parent=_FakeContext(None))
monkeypatch.setattr("typer._click.globals.get_current_context", lambda silent=False: ctx)
with pytest.raises(typer.Exit):
_util.fail("Oops.")
out, err = capsys.readouterr()
assert out == "ERROR Oops.\n"
assert err == ""
def test_fail_swallows_a_hint_rendering_failure(monkeypatch, capsys):
"""A future typer that restructures its private `_click` module, or any
other surprise in the hint path, degrades to no hint - never a crash in
place of the validation error `fail()` was already raising."""
def _boom(silent=False):
raise RuntimeError("typer._click restructured its context module")
monkeypatch.setattr("typer._click.globals.get_current_context", _boom)
with pytest.raises(typer.Exit) as exc:
_util.fail("Oops.")
assert exc.value.exit_code == 1
out, err = capsys.readouterr()
assert out == "ERROR Oops.\n"
assert err == ""