feat(config): T-1260 — reach lists its domains without importing them
`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>
This commit is contained in:
+67
-5
@@ -14,8 +14,8 @@ 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 T-1260 uses to register
|
||||
domains lazily.
|
||||
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`
|
||||
@@ -26,17 +26,79 @@ 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 and prints help. Since
|
||||
domains are registered lazily and none are eager, the callback is what makes an
|
||||
empty root legal.
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user