tools: fail() prints ON FAILURE lines on stderr after ERROR (#143)
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:
1 parent
5fe6003929
commit
63f566ff05
10 files changed
+403
-19
No files matched your search
@@ -291,7 +291,10 @@ Every `tools/wikitool` call has exactly four outcomes:
|
|||||||
|
|
||||||
1. **Success (exit 0).** Continue.
|
1. **Success (exit 0).** Continue.
|
||||||
2. **Validation error (exit 1 with an `ERROR` line).** Not transient - re-running unchanged
|
2. **Validation error (exit 1 with an `ERROR` line).** Not transient - re-running unchanged
|
||||||
fails identically. Read the message, fix the cause, retry **once** with corrected input.
|
fails identically. The `ERROR` line on stdout is followed by the command's ON FAILURE
|
||||||
|
reaction(s) on stderr - the same text `wikitool <cmd> -h` prints, without a second call -
|
||||||
|
or a bare `see: wikitool <cmd> -h` pointer where the record has none yet. Read the
|
||||||
|
message, fix the cause, retry **once** with corrected input.
|
||||||
3. **User clearance required (exit 42).** Not an error and not yours to resolve: show the
|
3. **User clearance required (exit 42).** Not an error and not yours to resolve: show the
|
||||||
command's output to the user verbatim and stop. See [Gates](#gates).
|
command's output to the user verbatim and stop. See [Gates](#gates).
|
||||||
4. **Unexpected error (timeout, crash, interrupted process).** Do not guess whether it
|
4. **Unexpected error (timeout, crash, interrupted process).** Do not guess whether it
|
||||||
|
|||||||
+17
-1
@@ -59,7 +59,7 @@ concern - readable here, never shipped as something to parse.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7.1.0-beta.21 - 2026-09-26 - Command records: NOTES is always a tuple of bullets; every record's examples are tested
|
## 7.1.0-beta.22 - 2026-09-26 - fail() prints the command's ON FAILURE lines on stderr
|
||||||
|
|
||||||
**Author:** Torben Nehmer
|
**Author:** Torben Nehmer
|
||||||
|
|
||||||
@@ -70,6 +70,7 @@ concern - readable here, never shipped as something to parse.
|
|||||||
**Medium impact**
|
**Medium impact**
|
||||||
- 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
|
||||||
- dist export no longer cuts the dist export record out of the shipped tools/CONTRACT.md
|
- dist export no longer cuts the dist export record out of the shipped tools/CONTRACT.md
|
||||||
|
- fail() prints the command's ON FAILURE lines on stderr
|
||||||
|
|
||||||
**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
|
||||||
@@ -342,6 +343,21 @@ over the real registry hold what the rewrite established: every command has at l
|
|||||||
example, and every command with a gate shows how its clearance is passed back in (`--confirm`,
|
example, and every command with a gate shows how its clearance is passed back in (`--confirm`,
|
||||||
`--confirm-rebase` or `--resume`).
|
`--confirm-rebase` or `--resume`).
|
||||||
|
|
||||||
|
### fail() prints the command's ON FAILURE lines on stderr
|
||||||
|
|
||||||
|
`_util.fail()` used to print only its `ERROR` line; the reaction a caller needs the moment a
|
||||||
|
command declines lived one lookup away, in `wikitool <cmd> -h`'s ON FAILURE section - exactly
|
||||||
|
the lookup AGENTS.md's own tool error contract already warned is the one most likely to be
|
||||||
|
skipped in the heat of a failure. `fail()` now prints that section's exit-1 causes (each with a
|
||||||
|
reaction) right after the `ERROR` line, on stderr and as plain text rather than through Rich - a
|
||||||
|
reaction can carry a literal `[--flag]`, which Rich would otherwise read as markup. A record
|
||||||
|
with no exit-1 cause of its own falls back to a bare `see: wikitool <cmd> -h` pointer;
|
||||||
|
`docs contract` is the one real command that hits it today. Nothing about stdout changes: a
|
||||||
|
command's output on success, or up to and including its `ERROR` line on failure, is
|
||||||
|
byte-identical to before. `cli.py`'s and `_util.py`'s lookup of the running command's
|
||||||
|
`cli_contract` path is now one shared function, `cli_contract.path_of`, in place of a private
|
||||||
|
copy that used to live only in `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
|
||||||
|
|||||||
@@ -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
|
way, for a human or an agent. Bare `tools/wikitool -h` prints the index below plus a pointer back
|
||||||
to this form.
|
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
|
## Commands
|
||||||
|
|
||||||
<!-- wikitool:commands -->
|
<!-- wikitool:commands -->
|
||||||
|
|||||||
+1
-16
@@ -189,21 +189,6 @@ app.command("sync")(git_publish.sync_command)
|
|||||||
app.command("doctor")(doctor.doctor_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:
|
def _render_options_text(command, ctx) -> str:
|
||||||
"""Click's own Arguments/Options sections, plain-formatted, for splicing
|
"""Click's own Arguments/Options sections, plain-formatted, for splicing
|
||||||
into a `cli_contract` record's OPTIONS section. A fixed width (not the
|
into a `cli_contract` record's OPTIONS section. A fixed width (not the
|
||||||
@@ -264,7 +249,7 @@ try:
|
|||||||
if ctx.parent is None:
|
if ctx.parent is None:
|
||||||
formatter.write(_render_root_help())
|
formatter.write(_render_root_help())
|
||||||
return
|
return
|
||||||
record = cli_contract.get(_contract_path(ctx))
|
record = cli_contract.get(cli_contract.path_of(ctx))
|
||||||
if record is None:
|
if record is None:
|
||||||
_original_format_help(self, ctx, formatter)
|
_original_format_help(self, ctx, formatter)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -77,6 +77,12 @@ class Failure:
|
|||||||
that is not one (an unreachable remote reported and skipped). An empty
|
that is not one (an unreachable remote reported and skipped). An empty
|
||||||
`reaction` renders the EXIT STATUS line only.
|
`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
|
`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
|
rendered as a `<label>: ` prefix on both lines; empty when the command
|
||||||
has one form, or when the cause already says it."""
|
has one form, or when the cause already says it."""
|
||||||
@@ -191,6 +197,24 @@ def get(path: str) -> Optional[CommandRecord]:
|
|||||||
return _REGISTRY.get(path)
|
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]:
|
def all_records() -> dict[str, CommandRecord]:
|
||||||
"""A copy of the registry, keyed by command path."""
|
"""A copy of the registry, keyed by command path."""
|
||||||
return dict(_REGISTRY)
|
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:
|
def render_text(rec: CommandRecord, options_text: str = "") -> str:
|
||||||
"""The full plain-text record, in NAME/SYNOPSIS/.../SEE ALSO order, for
|
"""The full plain-text record, in NAME/SYNOPSIS/.../SEE ALSO order, for
|
||||||
`wikitool <path> -h`. `options_text` is Click's own rendered Options
|
`wikitool <path> -h`. `options_text` is Click's own rendered Options
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
import sys
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional
|
from typing import Any, Dict, Optional
|
||||||
@@ -41,12 +42,50 @@ def declined() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def fail(msg: str) -> None:
|
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
|
global _declined
|
||||||
_declined = True
|
_declined = True
|
||||||
console.print(f"[bold red]ERROR[/bold red] {msg}")
|
console.print(f"[bold red]ERROR[/bold red] {msg}")
|
||||||
|
_print_failure_hint()
|
||||||
raise typer.Exit(code=1)
|
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:
|
def needs_clearance(msg: str) -> None:
|
||||||
"""Refuse with EXIT_NEEDS_CLEARANCE. The message is written to be shown to
|
"""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."""
|
a human verbatim - it is the whole user-facing artifact of this gate."""
|
||||||
|
|||||||
@@ -13,11 +13,16 @@ part being routed around.
|
|||||||
"""
|
"""
|
||||||
import errno
|
import errno
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import typer
|
||||||
|
|
||||||
from chemenu import cli
|
from chemenu import cli
|
||||||
|
from chemenu.commands import _util, run_budget
|
||||||
|
|
||||||
|
|
||||||
def read_lines(path):
|
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"]
|
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):
|
def test_an_ordinary_call_restores_the_real_streams_afterwards(monkeypatch, tmp_path):
|
||||||
monkeypatch.setenv("WIKI_TRACE_DIR", str(tmp_path))
|
monkeypatch.setenv("WIKI_TRACE_DIR", str(tmp_path))
|
||||||
monkeypatch.setenv("WIKITOOL_SESSION_ID", "restore-unit")
|
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)
|
and not any(flag in example for example in rec.examples for flag in clearing_flags)
|
||||||
]
|
]
|
||||||
assert missing == []
|
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"
|
||||||
@@ -302,3 +302,73 @@ def test_failure_code_outside_0_1_42_is_refused():
|
|||||||
def test_notes_as_one_string_is_refused():
|
def test_notes_as_one_string_is_refused():
|
||||||
with pytest.raises(ValueError, match="tuple of bullets"):
|
with pytest.raises(ValueError, match="tuple of bullets"):
|
||||||
_fixture_record(notes="One paragraph, the phase-1 form.")
|
_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"
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
"""Shared CLI helpers - the list format `--set` and the xref flags both use."""
|
"""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
|
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():
|
def test_escape_survives_surrounding_whitespace():
|
||||||
assert parse_list(r" A\, B , C ") == ["A, B", "C"]
|
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 == ""
|
||||||
Reference in new issue
Block a user