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>
95 lines
4.1 KiB
Python
95 lines
4.1 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
|
|
|
|
from tooling.core import console
|
|
|
|
|
|
class ReachGroup(TyperGroup):
|
|
"""Shared behaviour for every group in `reach` — the root and each domain.
|
|
|
|
Two things, both of which exist because the primary user is an agent
|
|
(D-263) rather than a person at a terminal.
|
|
|
|
**No arguments means "what is here?", not "you got it wrong".** Click's
|
|
`no_args_is_help` prints help and exits **2**, a usage error. But running
|
|
`reach` or `reach <domain>` bare is the DISCOVERY action — it is how the
|
|
tool gets learned from nothing — and a caller that branches on exit status
|
|
would read its own onboarding as a failure. So help is printed and the exit
|
|
is **0**. This does not weaken D-263's exit-code contract, which governs
|
|
*failures*; printing a command list is not one.
|
|
|
|
**An unknown name enumerates what exists.** Click's default is
|
|
`No such command 'x'`, which says you are wrong without saying what would
|
|
be right — the closed-set gap D-263 measured in pql. The list is already
|
|
registered on the group, so naming it costs a sort.
|
|
"""
|
|
|
|
def parse_args(self, ctx: typer.Context, args: list[str]) -> list[str]:
|
|
if not args and self.no_args_is_help and not ctx.resilient_parsing:
|
|
console.out(ctx.get_help())
|
|
ctx.exit(0)
|
|
return super().parse_args(ctx, args)
|
|
|
|
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)
|
|
|
|
|
|
# Kept as a name because domain routers read better with it, but there is no
|
|
# separate behaviour: a domain group IS a reach group.
|
|
ReachDomainGroup = ReachGroup
|
|
|
|
|
|
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,
|
|
# A SECOND rich path, separate from rich_markup_mode and easy to miss:
|
|
# typer's pretty-exception handler renders unhandled errors as box-art
|
|
# with syntax highlighting. That is the same log pollution
|
|
# rich_markup_mode=None prevents for help text, arriving through a
|
|
# different door — and it lands in the worst place, a hook log at the
|
|
# moment something has already gone wrong. Plain tracebacks instead.
|
|
pretty_exceptions_enable=False,
|
|
)
|