Files
settled-reach/tooling/core/errors.py
T
jpmschweitzerandClaude Opus 5 6f08cc9156 feat(config): T-1278 — the jobs domain, and typer.Exit is not a SystemExit
reach jobs list / status / log --follow / wait. A domain rather than core/,
because these verbs carry logic and state: they reconcile recorded status
against process liveness, tail a file from an offset, and relay an exit code.

Found a latent bug in already-committed code before building on it. typer.Exit
is a RuntimeError, not a SystemExit, so @handle_errors caught it like any other
unexpected exception: `raise typer.Exit(3)` inside a decorated command printed
"unexpected Exit: 3" and exited 1, silently discarding the requested code.
Nothing hit it because the check router had been converted to ReachError — but
jobs wait needs exactly this and it is what anyone would naturally write. Added
core/errors.ReachExit as the sanctioned control-flow exit, passed straight
through with no verdict. ReachError would have been wrong twice: a failure
verdict for a command that worked, and a demand for a fix= where there is no
remedy.

Reconciliation proved out on a real corpse rather than a simulated one — the
job stranded by the T-1277 bug, status "running" with its process long gone,
now reports as died. DIED is derived, never recorded, because a process killed
outright cannot write its own ending. It relays 137, never 0: a died job has no
exit code of its own and borrowing success points the exit-0 trap straight at
whatever gated on the run.

Second UTC bug of the same family as T-1276's: jobs list reported a job started
minutes earlier as running for 133m, because _parse used mktime on a UTC stamp
and silently added the offset to every duration.

console.render() is public now, so jobs log replays stored events through the
same path a live run prints them — a second renderer would drift, and the
divergence would surface exactly when someone is reading a log to find out what
went wrong.

test_jobs.py closes the gap T-1257 named: D-263 claims services are callable
without a CLI round trip, and nothing had ever demonstrated it, which left the
layering as unverified decoration. Every test here calls the service directly.

Not yet exercised, and said plainly: log --follow against a genuinely
long-running job. Nothing in reach runs long enough to tail yet. The offset
mechanics underneath are tested; the live loop waits for a slow domain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 17:02:36 +02:00

135 lines
5.3 KiB
Python

"""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
class ReachExit(Exception): # noqa: N818 control flow, not an error
"""Exit with a specific code, quietly. Not a failure.
For a command that must RELAY an exit code rather than report one — most
obviously `reach jobs wait`, which exits with the code of the job it waited
on. That is not `jobs wait` failing, so a `ReachError` would be wrong twice
over: it would print a failure verdict for a command that worked, and
demand a `fix=` for a situation with no remedy.
**Use this, never `typer.Exit`, inside a decorated command.** `typer.Exit`
is a `RuntimeError`, not a `SystemExit`, so `handle_errors` catches it like
any other unexpected exception — reporting "unexpected Exit: 3" and exiting
**1**, silently discarding the code that was asked for. The conformance
suite forbids it in routers so the trap cannot be re-entered.
"""
def __init__(self, exit_code: int = 0) -> None:
super().__init__(f"exit {exit_code}")
self.exit_code = exit_code
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 ReachExit as exc:
# Control flow, not a failure — no verdict, just the code.
raise SystemExit(exc.exit_code) from None
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",
)