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

+4 -1
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
7.1.0-beta.21 7.1.0-beta.22
+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 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
View File
@@ -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
+49
View File
@@ -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
+39
View File
@@ -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."""
+85
View File
@@ -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"
+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(): 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"
+132
View File
@@ -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 == ""