Every non-zero exit names the command that would fix it, and still exits non-zero. Both halves matter; the second is the one that gets lost, because a tool that explains itself beautifully and exits 0 looks MORE correct while having silently disabled its own gate. core/errors.py holds ReachError(message, fix=) and @handle_errors. core/logging.py holds @logged, emitting through console rather than a second sink — one output path, so there is nothing to drift. core/command.py composes them, and the order is load-bearing: handle_errors wraps logged, so the logger sees the original exception. Inverted, every failure would be recorded as "SystemExit" and the log would say nothing about what went wrong while looking like it worked. core/ raises SystemExit, not typer.Exit. 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 leaks the transport into every layer. The check router is retrofitted off its hand-rolled verdict-and-exit pattern — exactly the boilerplate this removes — and test_check_parity.py passes unchanged across the retrofit. That test predates the decorators and pins exit codes against the old script, so it is independent evidence, not a test tuned to match new behaviour. Unknown domains and unknown verbs now enumerate what exists instead of only saying no. That needed a shared group class, which collided with "no typer outside main.py and router.py" — resolved by sharpening the invariant rather than breaking it, since its purpose is that a SERVICE never knows it was called from a CLI. Transport now lives in main.py, router.py and core/cli.py; never in service.py, schemas.py or helpers.py. The upside is that cli.domain() carries the settings that were previously per-router decisions, including the load-bearing rich_markup_mode=None that one forgetful domain could have undone. test_conformance.py makes five invariants executable, AST-based rather than grep. Scoped to the package, not the 123 legacy scripts — and deliberately so: as T-1250 moves each script into domains/, it lands inside the scope and the rules start applying automatically, so the test's reach grows with the migration. Proven to fail before being trusted: removing @command and removing a fix= each produced a failure naming the file, the line and the reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
158 lines
5.2 KiB
Python
158 lines
5.2 KiB
Python
"""The single output path (D-263). Nothing else in `reach` prints.
|
|
|
|
Two channels, and keeping them apart is the whole design:
|
|
|
|
stdout the command's ACTUAL OUTPUT — the data the caller asked for.
|
|
Nothing else ever goes here, so `reach ... | jq` keeps working.
|
|
stderr the EVENT STREAM — progress, and the final verdict — as JSONL,
|
|
one object per line.
|
|
|
|
**Rendering happens at the sink, not at the emit site.** When stderr is a
|
|
terminal the events are rendered for a human; otherwise they are written as raw
|
|
JSONL. A live terminal and a job log are then the same artefact in two
|
|
presentations, which is what lets `reach jobs log` render for a person while a
|
|
conformance test asserts on the same bytes.
|
|
|
|
**Emitting is optional.** A command that never calls `event()` works normally,
|
|
and the push-gate checks deliberately emit nothing. This is a channel, not an
|
|
obligation — the point is that a long command *has somewhere to speak*, not that
|
|
every command must.
|
|
|
|
**The stream never replaces the verdict.** A remedy emitted at line 400 of 900
|
|
is technically printed and practically invisible, so `verdict()` prints once,
|
|
last, and is what a caller reads when it reads only one thing.
|
|
|
|
Stdlib only, and cheap to import: this module is on the gate path.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from typing import Any, TextIO
|
|
|
|
LEVELS: dict[str, int] = {"debug": 10, "info": 20, "warn": 30, "error": 40}
|
|
|
|
ENV_LEVEL = "SR_LOG_LEVEL"
|
|
ENV_FORMAT = "SR_OUTPUT_FORMAT" # "json" | "text" — overrides TTY detection
|
|
|
|
# Quiet by default so hooks are not spammed. T-1249's global --verbose lowers it.
|
|
_threshold = LEVELS.get(os.environ.get(ENV_LEVEL, "info").lower(), LEVELS["info"])
|
|
|
|
|
|
def set_level(name: str) -> None:
|
|
"""Set the minimum level that reaches the stream. Unknown names are a no-op."""
|
|
global _threshold
|
|
if name.lower() in LEVELS:
|
|
_threshold = LEVELS[name.lower()]
|
|
|
|
|
|
def is_verbose() -> bool:
|
|
"""True when debug-level events are being emitted.
|
|
|
|
Read by the error handler to decide whether an unexpected failure gets a
|
|
traceback or a one-liner. Verbosity is one setting, not two — a --verbose
|
|
that showed debug events but hid tracebacks would be a puzzle.
|
|
"""
|
|
return _threshold <= LEVELS["debug"]
|
|
|
|
|
|
def out(text: str = "") -> None:
|
|
"""Write to stdout — the command's actual output, never commentary."""
|
|
print(text, file=sys.stdout, flush=True)
|
|
|
|
|
|
def event(
|
|
message: str,
|
|
*,
|
|
level: str = "info",
|
|
phase: str | None = None,
|
|
progress: float | None = None,
|
|
**extra: Any,
|
|
) -> None:
|
|
"""Emit one progress event onto the stream.
|
|
|
|
Suppressed entirely if below the current level, so a debug-chatty service
|
|
costs nothing in a hook.
|
|
"""
|
|
if LEVELS.get(level, LEVELS["info"]) < _threshold:
|
|
return
|
|
_write(
|
|
{
|
|
"ts": _now(),
|
|
"level": level,
|
|
"phase": phase,
|
|
"message": message,
|
|
"progress": progress,
|
|
**extra,
|
|
}
|
|
)
|
|
|
|
|
|
def verdict(message: str, *, ok: bool = True, fix: str | None = None) -> None:
|
|
"""Print the final summary: once, last, and never suppressed by the level.
|
|
|
|
`fix` is the command that would resolve a failure — the contract at the top
|
|
of D-263. It is carried as a structured field so a caller can extract it
|
|
without parsing prose.
|
|
"""
|
|
_write(
|
|
{
|
|
"ts": _now(),
|
|
"level": "info" if ok else "error",
|
|
"kind": "verdict",
|
|
"ok": ok,
|
|
"message": message,
|
|
"fix": fix,
|
|
}
|
|
)
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now(timezone.utc).isoformat(timespec="milliseconds")
|
|
|
|
|
|
def _write(payload: dict[str, Any]) -> None:
|
|
stream: TextIO = sys.stderr
|
|
if _render_as_text(stream):
|
|
stream.write(_render(payload))
|
|
else:
|
|
# Drop None-valued optionals so a log line carries what happened, not a
|
|
# census of the fields that did not apply.
|
|
compact = {k: v for k, v in payload.items() if v is not None}
|
|
stream.write(json.dumps(compact, ensure_ascii=False) + "\n")
|
|
stream.flush()
|
|
|
|
|
|
def _render_as_text(stream: TextIO) -> str | bool:
|
|
override = os.environ.get(ENV_FORMAT, "").lower()
|
|
if override == "text":
|
|
return True
|
|
if override == "json":
|
|
return False
|
|
try:
|
|
return stream.isatty()
|
|
except (AttributeError, ValueError):
|
|
# A closed or substituted stream: prefer the machine format, which is
|
|
# the one that stays parseable when nobody is watching.
|
|
return False
|
|
|
|
|
|
def _render(payload: dict[str, Any]) -> str:
|
|
message = payload.get("message", "")
|
|
if payload.get("kind") == "verdict":
|
|
if payload.get("ok"):
|
|
return f"{message}\n"
|
|
fix = payload.get("fix")
|
|
tail = f"\n\nFix: {fix}\n" if fix else "\n"
|
|
return f"{message}{tail}"
|
|
|
|
prefix = {"warn": "warning: ", "error": "error: "}.get(payload.get("level", ""), "")
|
|
phase = payload.get("phase")
|
|
scope = f"[{phase}] " if phase else ""
|
|
progress = payload.get("progress")
|
|
pct = f" ({progress * 100:.0f}%)" if isinstance(progress, (int, float)) else ""
|
|
return f"{scope}{prefix}{message}{pct}\n"
|