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>
145 lines
6.3 KiB
Python
145 lines
6.3 KiB
Python
"""`reach` — one command for every repo tool (D-263).
|
|
|
|
**This file is a router and nothing else.** No logic, no I/O, no pydantic, no
|
|
domain imports at module level. It is the file most likely to accumulate "just
|
|
one small thing", and the only defence is that it stays short enough that an
|
|
addition is obvious in review.
|
|
|
|
The root is a `typer.Typer`, and the reason is worth recording because the
|
|
original plan was different. T-1259 specified a `click.Group` root, on the
|
|
theory that it would keep typer off the `reach --help` path. **That is no longer
|
|
possible: typer vendors click as of 0.26.0** — there is no top-level `click`
|
|
package to import, and the docs are explicit that "extracting the internal Click
|
|
app" is unsupported. Mixing a real `click.Group` root with typer sub-apps would
|
|
mean two different Click implementations in one process.
|
|
|
|
So the customisation surface is `typer.Typer(cls=...)` with a `TyperGroup`
|
|
subclass, which is the supported path and is what `LazyDomainGroup` below uses
|
|
to register domains lazily.
|
|
|
|
`rich_markup_mode=None` is not a style preference — it is worth 94 ms of the
|
|
168 ms an empty `--help` otherwise costs, and it keeps `rich` and `pygments`
|
|
off the import path entirely (verified absent from `sys.modules`). It also
|
|
stops typer drawing box-art help, which it does **even when stdout is a pipe**,
|
|
so hook logs and agent output stay readable. One line to revert if the boxes
|
|
are ever wanted more than the milliseconds.
|
|
|
|
The callback below is not decoration. A `typer.Typer` with **no commands and no
|
|
callback** raises `RuntimeError: Could not get a command for this Typer
|
|
instance` at build time; with a callback it builds fine. Since every domain is
|
|
registered lazily and none is eager, the callback is what makes the root legal.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
|
|
import typer
|
|
from typer.core import TyperGroup
|
|
|
|
# The domain registry: name -> (import target, one-line help).
|
|
#
|
|
# This table is the ONLY thing `reach --help` reads. The short help lives here
|
|
# as a literal string rather than being pulled off the loaded command, because
|
|
# reading it off the command is precisely what would import the world — see
|
|
# LazyDomainGroup.format_commands.
|
|
#
|
|
# Adding a domain is adding a line here plus a `router.py` that exposes `app`.
|
|
DOMAINS: dict[str, tuple[str, str]] = {
|
|
"check": (
|
|
"tooling.domains.check.router:app",
|
|
"Consistency gates — the checks the push hook runs",
|
|
),
|
|
}
|
|
|
|
|
|
class LazyDomainGroup(TyperGroup):
|
|
"""Lists domains without importing them; imports exactly the one invoked.
|
|
|
|
Three overrides, and the third is the one that matters. `TyperGroup`'s own
|
|
`format_commands` loops over `list_commands` calling `get_command` on each,
|
|
just to read a short help string off the loaded command — which, with lazy
|
|
loading underneath, imports every domain in the registry to render `--help`.
|
|
That would defeat the whole mechanism silently, while looking correct.
|
|
|
|
So `format_commands` is overridden to read help from `DOMAINS` and never
|
|
touch `get_command`. The cost of `reach --help` is then flat no matter how
|
|
many domains exist, which is the property that has to hold as this grows
|
|
from one domain to a dozen.
|
|
"""
|
|
|
|
def list_commands(self, ctx: typer.Context) -> list[str]:
|
|
return sorted({*super().list_commands(ctx), *DOMAINS})
|
|
|
|
def get_command(self, ctx: typer.Context, cmd_name: str):
|
|
if cmd_name in DOMAINS:
|
|
return _load_domain(cmd_name)
|
|
return super().get_command(ctx, cmd_name)
|
|
|
|
def resolve_command(self, ctx: typer.Context, args: list[str]):
|
|
# Closed-set enumeration (D-263). Click's default is "No such command
|
|
# 'x'" — which tells you that you are wrong without telling you what
|
|
# would be right, the exact gap measured in pql. The accepted set is
|
|
# sitting in DOMAINS, already loaded for --help, so naming it is free.
|
|
if args and args[0] not in DOMAINS and super().get_command(ctx, args[0]) is None:
|
|
listed = ", ".join(sorted({*DOMAINS, *super().list_commands(ctx)}))
|
|
ctx.fail(f"unknown domain {args[0]!r}\n\nChoose one of: {listed}")
|
|
return super().resolve_command(ctx, args)
|
|
|
|
def format_commands(self, ctx: typer.Context, formatter) -> None:
|
|
# Deliberately does NOT call get_command. See the class docstring.
|
|
rows = [(name, short) for name, (_target, short) in sorted(DOMAINS.items())]
|
|
for name in sorted(super().list_commands(ctx)):
|
|
command = super().get_command(ctx, name)
|
|
if command is not None and not command.hidden:
|
|
rows.append((name, command.get_short_help_str(80)))
|
|
if rows:
|
|
with formatter.section("Domains"):
|
|
formatter.write_dl(sorted(rows))
|
|
|
|
|
|
def _load_domain(name: str):
|
|
"""Import one domain's router and convert its Typer app to a command."""
|
|
target, _short = DOMAINS[name]
|
|
module_name, _, attr = target.partition(":")
|
|
module = importlib.import_module(module_name)
|
|
return typer.main.get_command(getattr(module, attr))
|
|
|
|
|
|
cli = typer.Typer(
|
|
name="reach",
|
|
cls=LazyDomainGroup,
|
|
help="Repo tooling for The Settled Reach.\n\nRun `reach <domain> --help` to see what a domain can do.",
|
|
no_args_is_help=True,
|
|
add_completion=False,
|
|
rich_markup_mode=None,
|
|
context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 100},
|
|
)
|
|
|
|
|
|
@cli.callback()
|
|
def root(
|
|
verbose: bool = typer.Option(
|
|
False, "--verbose", "-v", help="Show progress events and full tracebacks."
|
|
),
|
|
no_input: bool = typer.Option(
|
|
False, "--no-input", help="Never prompt. Hooks and agents should always pass this."
|
|
),
|
|
) -> None:
|
|
"""Global options, declared once here so every domain inherits them.
|
|
|
|
A per-domain copy of these is how the two would drift apart — one router
|
|
growing a `--verbose` that sets a different level, or forgetting `--no-input`
|
|
entirely.
|
|
|
|
Note `--no-input` is about PROMPTING, not output format. Console already
|
|
chooses JSONL versus rendered text from `isatty` with an `SR_OUTPUT_FORMAT`
|
|
override; making this flag a second, conflicting way to say the same thing
|
|
would leave nobody sure which one wins.
|
|
"""
|
|
from tooling.core import console, runtime
|
|
|
|
if verbose:
|
|
console.set_level("debug")
|
|
runtime.set_no_input(no_input)
|