`reach --help` renders from a declaration table and imports nothing. The cost of help is now flat as the registry grows, which is the property that has to hold going from one domain to a dozen. The trap is real and was confirmed in typer's vendored source rather than assumed from upstream Click: TyperGroup.format_commands loops over list_commands calling get_command on each, purely to read a short help string off the loaded command. With lazy loading underneath, that imports every domain in the registry to render --help — while the output looks entirely correct. Nothing observable changes; only the import graph does. So the test asserts on sys.modules, and it was proven to fail before being trusted. Disabling the format_commands override made it fail and name the cause, listing all five leaked check modules. It also carries a positive control — invoking a domain must import its service — because without one, "nothing was imported" would pass equally for a loader that is simply broken, and it fails on an empty registry, which would otherwise satisfy everything vacuously. The check domain is created here because the test needs a subject: a stub raising NotImplementedError would have been committed dead code. That takes the port out of T-1262, which is rescoped to what it still owns — pydantic schemas, byte-for-byte output parity on the drift path, and the failure tests. The old tooling/check-client-version script stays in place and stays wired to the pre-push hook; the deprecation window is deliberate. One Typer behaviour worth knowing before every future domain: a single-command app collapses into a bare command, so `reach check client-version` failed with "unexpected extra argument" until the router got a callback. Same mechanism as the root callback, different symptom. Help now works at every level, closing item 5 of T-1248. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
113 lines
4.8 KiB
Python
113 lines
4.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 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 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() -> None:
|
|
"""Present so an empty root is legal — see the module docstring."""
|