"""Failures that teach, as a decorator rather than call-site discipline (D-263). The contract: **every non-zero exit prints the command that would fix it, and still exits non-zero.** Both halves matter, and the second is the one that gets lost. A tool that explains itself beautifully and exits 0 has silently disabled its own gate — and the explanation makes it look *more* correct, not less, which is why this is a decorator with a test behind it and not a convention. No typer or click import here. `core/` is transport substrate: a service must be callable from a test, another service, or a future second front end, and an exception type that only makes sense inside a CLI would leak the transport into every layer. Exit is raised as a plain `SystemExit`, which click passes through untouched. """ from __future__ import annotations import functools from collections.abc import Callable, Iterable from typing import Any, TypeVar from tooling.core import console F = TypeVar("F", bound=Callable[..., Any]) class ReachError(Exception): """A failure the caller can act on. `fix` is not optional in spirit — it is the whole point. If you cannot name a next command, you probably do not understand the failure well enough to report it yet, and a message that only says "no" is the thing this exists to replace. """ def __init__(self, message: str, *, fix: str | None = None, exit_code: int = 1) -> None: super().__init__(message) self.message = message self.fix = fix # Non-zero by construction. A ReachError carrying exit_code=0 would be a # contradiction — and exactly the silent-gate failure described above. self.exit_code = exit_code if exit_code != 0 else 1 def unknown_choice(kind: str, given: str, accepted: Iterable[str]) -> ReachError: """Reject a value from a known finite set, naming the whole set. Whenever the accepted values are knowable, print them. This is the specific gap D-263 measured in pql — an invalid ticket status rejected without naming the six valid ones — which leaves the caller grepping source to guess. """ options = sorted(accepted) listed = ", ".join(options) if options else "(none available)" return ReachError( f"unknown {kind}: {given!r}", fix=f"choose one of: {listed}", exit_code=2, ) def handle_errors(func: F) -> F: """Render a failure through `console`, then exit with its code. Deliberately catches nothing it cannot improve on. `SystemExit` passes through — a decision to exit has already been made and re-reporting it would double the output. """ @functools.wraps(func) def wrapper(*args: Any, **kwargs: Any) -> Any: try: return func(*args, **kwargs) except ReachError as exc: # The verdict prints ONCE, LAST, after whatever the command streamed. # A remedy emitted mid-stream at line 400 of 900 is technically # printed and practically invisible. console.verdict(exc.message, ok=False, fix=exc.fix) raise SystemExit(exc.exit_code) from exc except SystemExit: raise except Exception as exc: _report_unexpected(exc) raise SystemExit(1) from exc return wrapper # type: ignore[return-value] def _report_unexpected(exc: Exception) -> None: """An exception nobody anticipated still exits non-zero and still says something. The traceback goes behind `--verbose` rather than at a user who cannot act on it; the one-line form names the flag that reveals it, so the next step is always visible even when the failure was not foreseen. """ if console.is_verbose(): import traceback console.event(traceback.format_exc().rstrip(), level="error") console.verdict( f"unexpected {type(exc).__name__}: {exc}", ok=False, fix="the traceback above is the whole story — this is a bug in reach, not in your input", ) return console.verdict( f"unexpected {type(exc).__name__}: {exc}", ok=False, fix="re-run with --verbose for the traceback", )