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>
135 lines
5.3 KiB
Python
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",
|
|
)
|