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>
65 lines
2.6 KiB
Python
65 lines
2.6 KiB
Python
"""The one module in `core/` that knows about Typer.
|
|
|
|
**Why this is not a violation of the layering.** The invariant D-263 states is
|
|
that typer/click appear only in `main.py` and `router.py` — and its purpose is
|
|
that a *service* must never know it was called from a CLI. A shared group class
|
|
is transport by definition; the alternative is copying the same subclass into
|
|
every `router.py`, where the copies drift and only some domains end up
|
|
enumerating their verbs. So the invariant is refined rather than broken:
|
|
|
|
no typer/click in service.py, schemas.py or helpers.py — ever.
|
|
transport lives in main.py, router.py, and this module.
|
|
|
|
Keep that bound. If something here stops being about *transport*, it belongs
|
|
somewhere else.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import typer
|
|
from typer.core import TyperGroup
|
|
|
|
|
|
class ReachDomainGroup(TyperGroup):
|
|
"""A domain group whose unknown-verb error names the verbs that exist.
|
|
|
|
Click's default is `No such command 'x'` — which tells you that you are
|
|
wrong without telling you what would be right. That is the closed-set gap
|
|
D-263 measured in pql (an invalid status rejected without naming the six
|
|
valid ones), and the fix is nearly free: the verb list is already registered
|
|
on the group, so enumerating it costs a sort.
|
|
"""
|
|
|
|
def resolve_command(self, ctx: typer.Context, args: list[str]):
|
|
if args and self.get_command(ctx, args[0]) is None:
|
|
listed = ", ".join(sorted(self.list_commands(ctx)))
|
|
ctx.fail(f"unknown command {args[0]!r}\n\nChoose one of: {listed}")
|
|
return super().resolve_command(ctx, args)
|
|
|
|
|
|
def domain(name: str, help: str) -> typer.Typer:
|
|
"""Build a domain's Typer app with the house settings applied.
|
|
|
|
Every domain router should use this rather than calling `typer.Typer`
|
|
directly, so the settings that are easy to forget are not per-router
|
|
decisions:
|
|
|
|
- `cls=ReachDomainGroup` so unknown verbs enumerate.
|
|
- `rich_markup_mode=None` — load-bearing, not cosmetic: it keeps `rich` and
|
|
`pygments` off the import path, and stops typer drawing box-art help even
|
|
when stdout is a pipe, which would litter hook logs.
|
|
- `no_args_is_help` so a bare `reach <domain>` says what it can do.
|
|
|
|
Note the caller still needs a `@app.callback()` on the router: Typer
|
|
collapses a single-command app into a bare command, and without the callback
|
|
`reach <domain> <verb>` fails with "unexpected extra argument".
|
|
"""
|
|
return typer.Typer(
|
|
name=name,
|
|
help=help,
|
|
cls=ReachDomainGroup,
|
|
no_args_is_help=True,
|
|
add_completion=False,
|
|
rich_markup_mode=None,
|
|
)
|