Files
soleprint/soleprint/station/tools/common/cli.py
2026-09-24 00:58:19 -03:00

336 lines
14 KiB
Python

"""
The shape a station tool's command line has.
Every tool here grew its own copy of the same three things: argparse subcommands
wired to `cmd_*` functions, a flag vocabulary that is nearly but not quite the
same between tools, and an error convention. shuntgen spells `-s` as --spec in
one subcommand and --source in another; modelgen calls the same idea --source
everywhere; tester has neither. The differences are accidents, not decisions.
This is that shared shape, factored out.
## Where this lives, and why it lives in two places
It was written inside `histgen/`, with exactly one user, on the principle that a
scaffold earns adoption by being there when the next tool is written rather than
by a flag-day. It has earned it; this is the promoted copy, and `station/tools/
common/` is now the source.
It is **both imported and vendored**, which is deliberate:
shuntgen, tester import it. `from ..common.cli import Tool, fail`
Neither is standalone, so there is nothing to protect.
histgen, modelgen vendor a copy at `<tool>/cli.py`.
Both are designed to leave the tree — modelgen is a
published pip package, and histgen/__init__.py says the
directory "can be copied out of soleprint and run on its
own — a repo that needs a history seeded is, by
definition, not one that already has this framework on
its path." An import would make that false.
Vendoring is safe only if drift is visible, so the copies are registered as
`soleprint-cli` in `registry.json` and `ctrl/spr.py diff` reports it — the same
arrangement that already keeps `soleprint-ui` honest across `unt` and `mpr`.
python ctrl/spr.py diff soleprint-cli soleprint/station/tools/histgen
## Import it relatively, or by its full path -- never as top-level `common`
**`soleprint/common/` already exists** and holds auth, theme and ui. Python puts
the working directory ahead of PYTHONPATH, so from `soleprint/` a bare
`import common.cli` finds that package and fails with "No module named
'common.cli'". Two spellings are safe and one is not:
from ..common.cli import Tool, fail # inside station/tools -- safe
from .cli import Tool, fail # vendored copy -- safe
import station.tools.common.cli # full path -- safe
import common.cli # depends on cwd -- DO NOT
`common/selftest.py` asserts all three outcomes, so the day the collision goes
away the checks say so rather than quietly passing.
## Using it
tool = Tool("histgen", __doc__)
tool.command("scan", cmd_scan, "Read the repo and cache what was read.")
tool.argument("scan", "repo", help="The tree to read.")
tool.run()
Conventions it encodes, so they stop being re-decided:
--source/-s what to read --output/-o where to write
--name/-n what to call it --force/-f write anyway
--dry-run print, do not do
Progress goes through say(), NOT print(). say() writes to stdout normally and
to stderr when --output is "-", because that is when stdout carries the
artifact and a stray progress line is a JSON syntax error one pipe stage
later. See common/entity.py. Errors go to stderr prefixed
'Error: ' and exit 1 — never a traceback, which tells a user nothing they
can act on. `fail()` is the only exit path for an *error*; a command that
ends badly but not wrongly (tests failed, drift found) returns an int and
`Tool.run` exits with it.
An alias for a flag that is `required=True` must be a SECOND OPTION STRING on
the same add_argument, never a second argument with the same dest. argparse
checks `required` per action, so a separate alias action leaves the real one
unsatisfied and can never be used. See shuntgen's --source/-s/--spec.
Heavy imports live inside the cmd_* function, never at module top, so
`--help` stays instant and an optional dependency only costs the one
subcommand that needs it.
"""
import argparse
import importlib
import sys
# The flags worth having exactly one spelling of. A tool adds its own on top;
# it does not redefine these.
COMMON = {
"source": dict(flags=("--source", "-s"), help="What to read."),
"output": dict(flags=("--output", "-o"), help="Where to write."),
"name": dict(flags=("--name", "-n"), help="What to call it."),
"force": dict(flags=("--force", "-f"), action="store_true",
help="Write even if the destination is occupied."),
"dry_run": dict(flags=("--dry-run",), action="store_true",
help="Print what would happen; change nothing."),
}
# Set by Tool.run when the command's --output is "-", i.e. stdout is carrying
# the data. Process-wide because a process runs exactly one command; `say` has
# to be callable from a cmd_* that was handed `args` and never saw the Tool.
_DATA_ON_STDOUT = False
def say(*args, **kwargs):
"""Progress, printed where it cannot corrupt the data channel.
`print()` to stdout is right until the day stdout carries the artifact, and
then it silently produces invalid JSON halfway down a pipe. Tools use this
instead and stop having to think about it.
"""
kwargs.setdefault("file", sys.stderr if _DATA_ON_STDOUT else sys.stdout)
print(*args, **kwargs)
def data_on_stdout():
return _DATA_ON_STDOUT
def fail(message, hint=None):
"""The only way out on error: a line a user can act on, never a traceback."""
print(f"Error: {message}", file=sys.stderr)
if hint:
print(f" {hint}", file=sys.stderr)
sys.exit(1)
def refuse_to_clobber(path, force, marker, what):
"""
Regenerating is fine; overwriting something we did not write is not.
Lifted from shuntgen, which refuses a non-empty output directory unless it
carries the file its own generator leaves behind. The check is cheap and the
failure it prevents — silently eating a directory someone hand-wrote — is
not recoverable from.
"""
if force or not path.exists():
return
if not any(path.iterdir()):
return
if (path / marker).exists():
return
fail(f"{path} already exists and was not written by {what}.",
"Pick another path, or pass --force to write into it anyway.")
class Registry:
"""
A name -> implementation map that imports nothing in order to list itself.
The problem it solves, measured rather than assumed:
import modelgen 119 modules, 49 ms
import graphgen 1 module, 1 ms
import histgen 1 module, 3 ms
modelgen's `GENERATORS` is a dict of twelve names to nine *classes*, all
imported at the top of `generator/__init__.py`. So `modelgen --help` pays
for csv, zipfile, lzma, bz2, xml and ast -- the ODS extractor's machinery --
before it prints a line of text. That breaks the deferred-import rule this
module's docstring states, and it breaks it at the registry rather than
inside a cmd_*, which is why moving one import does not fix it.
The shape that works is a name -> **import path** map resolved on use,
instead of a name -> **class** dict resolved on import. `Profile.available()`
in atlas2/docgen reached the same answer from the other direction, by
globbing files and importing nothing to say what exists.
GENERATORS = Registry({
"pydantic": ".pydantic:PydanticGenerator",
"django": ".django:DjangoGenerator",
"typescript": ".typescript:TypeScriptGenerator",
}, package=__name__, what="generator",
aliases={"ts": "typescript"})
GENERATORS.names() # no import -- safe in help text and choices=
GENERATORS.load("ts") # imports exactly one module
Aliases are first-class because the real registry has them: `ts`, `proto`
and `jsonschema` are second names for generators that already exist. Keeping
them separate from `entries` is what lets `names()` list each generator once
while `choices()` still accepts every spelling a user might type.
"""
def __init__(self, entries, *, package=None, what="target", aliases=None):
self._entries = dict(entries)
self._aliases = dict(aliases or {})
self._package = package
self._what = what
self._loaded = {}
unknown = {a: t for a, t in self._aliases.items() if t not in self._entries}
if unknown:
# A typo here would surface as a user-facing "unknown generator" for
# a name the registry plainly lists. Cheaper to refuse at import.
raise ValueError(
f"alias target(s) not in the registry: {sorted(unknown.items())}")
# -- listing: none of this imports anything --------------------------
def names(self):
"""Canonical names, one per implementation."""
return sorted(self._entries)
def choices(self):
"""Every accepted spelling, for argparse `choices=`."""
return sorted(set(self._entries) | set(self._aliases))
def aliases_of(self, name):
return sorted(a for a, t in self._aliases.items() if t == name)
def help(self, lead=None):
"""A help string listing what exists, without importing any of it."""
parts = []
for name in self.names():
alts = self.aliases_of(name)
parts.append(f"{name} ({', '.join(alts)})" if alts else name)
listed = ", ".join(parts)
return f"{lead} {listed}." if lead else listed
def __contains__(self, name):
return name in self._entries or name in self._aliases
def __iter__(self):
return iter(self.names())
def __len__(self):
return len(self._entries)
# -- resolution ------------------------------------------------------
def resolve(self, name):
"""Follow an alias to its canonical name. Imports nothing."""
if name in self._aliases:
return self._aliases[name]
if name in self._entries:
return name
fail(f"unknown {self._what}: {name!r}",
f"Known: {', '.join(self.choices())}")
def load(self, name):
"""The implementation itself. Imports exactly one module, once."""
canonical = self.resolve(name)
if canonical not in self._loaded:
target = self._entries[canonical]
module_path, _, attr = target.partition(":")
if not attr:
fail(f"malformed registry entry for {canonical!r}: {target!r}",
'Expected "module.path:AttributeName".')
try:
module = importlib.import_module(module_path, self._package)
except ImportError as exc:
# An optional dependency is a different failure from a typo, and
# a traceback here would bury which of the two it is.
fail(f"{self._what} {canonical!r} could not be loaded: {exc}",
"Its dependency is probably not installed.")
try:
self._loaded[canonical] = getattr(module, attr)
except AttributeError:
fail(f"{module_path} has no {attr!r}, which {canonical!r} names.")
return self._loaded[canonical]
def __getitem__(self, name):
return self.load(name)
def _prog(package, name):
"""
How this tool was actually invoked, for the usage line.
The folder is meant to be copied out and run on its own, so a usage line
hardcoding `python -m station.tools.histgen` is wrong the moment it is —
it names a path that does not exist on the machine reading it.
"""
return f"python -m {package or name}"
class Tool:
"""A tool's whole command line: subcommands, shared flags, one exit path."""
def __init__(self, name, description, package=None):
self.name = name
self.parser = argparse.ArgumentParser(
prog=_prog(package, name),
description=(description or "").strip().split("\n\n")[0],
formatter_class=argparse.RawDescriptionHelpFormatter,
)
self._subparsers = self.parser.add_subparsers(dest="command", required=True)
self._commands = {}
def command(self, verb, func, help_text):
"""Register a subcommand. `func` takes parsed args and returns None."""
sub = self._subparsers.add_parser(verb, help=help_text, description=help_text)
sub.set_defaults(func=func)
self._commands[verb] = sub
return sub
def argument(self, verb, *args, **kwargs):
"""Add a positional or flag to one subcommand."""
self._commands[verb].add_argument(*args, **kwargs)
def common(self, verb, *names, **overrides):
"""Add shared flags by name, so their spelling is decided in one place."""
for key in names:
spec = dict(COMMON[key])
flags = spec.pop("flags")
spec.update(overrides.get(key, {}))
self._commands[verb].add_argument(*flags, **spec)
def run(self, argv=None):
"""Dispatch, and turn the ways a command ends into exit codes.
A `cmd_*` returning a non-zero int exits with it. That is for an outcome
that is not an error — tester's "3 tests failed" is a true report, not a
malfunction, and routing it through fail() would print "Error:" in front
of a result the user asked for. Returning None, which almost every
command does, exits 0.
"""
args = self.parser.parse_args(argv)
global _DATA_ON_STDOUT
_DATA_ON_STDOUT = str(getattr(args, "output", "") or "") == "-"
try:
result = args.func(args)
if isinstance(result, int) and result != 0:
sys.exit(result)
except KeyboardInterrupt:
# Ctrl-C is a decision, not a crash. Say so and leave quietly.
print("\nInterrupted.", file=sys.stderr)
sys.exit(130)
except BrokenPipeError:
# `... | head` closes the pipe early; that is the caller's business.
sys.exit(0)