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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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 == ""
|
||||
Reference in new issue
Block a user