All five gates now live in the check domain. canvas-version produces byte-identical output to the original on the live tree. It is the first real consumer of core/process.run. The git calls pass check=False deliberately: a git failure here is not an error to report but a signal that there is nothing to compare, since a fresh clone with no remote is a legitimate state rather than a broken one. The argv-list and missing-binary guards still apply. Its two skips are kept distinct from its pass. NO_BASE and DIFF_FAILED exit 0, as does CLEAN — but only CLEAN means the gate actually looked at something. Collapsing them would hide a gate that had silently stopped running, which for this check in particular is the exact failure it exists to prevent. Found a second rich path while a NameError was rendering as a full-width box-drawn traceback: typer's pretty-exception handler is a different mechanism from rich_markup_mode, and setting one does nothing about the other. Same log pollution T-1259 thought it had closed, arriving through another door and landing in the worst place — a hook log at the moment something has already gone wrong. pretty_exceptions_enable=False now on the root and on every domain built by cli.domain(). test_canvas_version_check.py moves with the code it guards. It had been loading the extensionless script through a SourceFileLoader and reaching canvas_sources by sys.path insert, both only because tooling/ was not importable. Second instance of that debt evaporating on contact. What it asserts is unchanged, which is the point: diff_has_version_bump was kept pure in the port so its six properties still hold without constructing git history. Also restores an import the check router dropped in T-1267 when it moved to cli.domain() — caught by running the command rather than by reading it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
214 lines
8.8 KiB
Python
214 lines
8.8 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 tooling.core.cli import ReachGroup
|
|
|
|
# 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",
|
|
),
|
|
"jobs": (
|
|
"tooling.domains.jobs.router:app",
|
|
"Detached runs — status, logs and outcomes",
|
|
),
|
|
"dev": (
|
|
"tooling.domains.dev.router:app",
|
|
"Developer environment and self-diagnosis",
|
|
),
|
|
}
|
|
|
|
|
|
class LazyDomainGroup(ReachGroup):
|
|
"""Lists domains without importing them; imports exactly the one invoked.
|
|
|
|
Extends `ReachGroup`, so bare `reach` prints the domain list and exits 0
|
|
like every domain group does. Only the laziness and the domain-specific
|
|
wording live here.
|
|
|
|
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,
|
|
# See core/cli.py — a second rich path that renders unhandled exceptions as
|
|
# box-art, arriving through a different door than rich_markup_mode.
|
|
pretty_exceptions_enable=False,
|
|
context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 100},
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
"""Console entry point — `reach`.
|
|
|
|
Exists so a detached child records its completion at the PROCESS's exit
|
|
rather than at a command's. Recording it inside `@command` looked right and
|
|
was subtly wrong: a child that fails before any command runs — bad
|
|
arguments, an unknown verb, an import error — never reaches that decorator,
|
|
so its metadata said `running` forever. A failed job that looks busy is the
|
|
exit-0 trap wearing a new disguise, and worse than the original because
|
|
nothing is watching a background job.
|
|
|
|
Here, every exit path passes through one `finally`.
|
|
"""
|
|
exit_code = 0
|
|
try:
|
|
cli()
|
|
except SystemExit as exc:
|
|
exit_code = exc.code if isinstance(exc.code, int) else 1
|
|
raise
|
|
except BaseException:
|
|
exit_code = 1
|
|
raise
|
|
finally:
|
|
# Imported lazily and only when detached, so `reach --help` never pays
|
|
# for it — the laziness T-1260 protects applies here too.
|
|
import os
|
|
|
|
from tooling.core import jobs
|
|
|
|
if os.environ.get(jobs.ENV_JOB_ID):
|
|
from tooling.core import process
|
|
|
|
process.finish_if_detached(exit_code)
|
|
|
|
|
|
@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."
|
|
),
|
|
detach: bool = typer.Option(
|
|
False, "--detach", help="Run in the background; print a job id and return at once."
|
|
),
|
|
) -> 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)
|
|
|
|
if detach:
|
|
# Handled here, before any domain loads, because detaching is a property
|
|
# of the INVOCATION rather than of the verb — every command gets it and
|
|
# no command implements it. The child re-runs this same argv with
|
|
# --detach stripped, so it does the work instead of forking again.
|
|
from tooling.core import process
|
|
|
|
job_id = process.spawn_detached(process.current_argv())
|
|
console.out(job_id)
|
|
console.verdict(
|
|
f"started job {job_id} — this exit status means STARTED, not succeeded",
|
|
fix=None,
|
|
)
|
|
raise typer.Exit(0)
|