336 lines
14 KiB
Python
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)
|