diff --git a/soleprint/station/tools/common/__init__.py b/soleprint/station/tools/common/__init__.py new file mode 100644 index 0000000..bca429e --- /dev/null +++ b/soleprint/station/tools/common/__init__.py @@ -0,0 +1,14 @@ +""" +What the station tools share. + +One thing so far — `cli.py`, the command-line shape every tool was growing its +own slightly different copy of. It is the source; `histgen` and `modelgen` +vendor it because they leave the tree, `shuntgen` and `tester` import it because +they do not. See `cli.py`'s docstring for why that split is deliberate. + +Nothing here imports anything outside the standard library, and nothing here +imports a tool. A shared module that reaches back into its users is not shared, +it is circular. +""" + +__all__ = ["cli"] diff --git a/soleprint/station/tools/common/cli.py b/soleprint/station/tools/common/cli.py new file mode 100644 index 0000000..c782052 --- /dev/null +++ b/soleprint/station/tools/common/cli.py @@ -0,0 +1,158 @@ +""" +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 `/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 + +## Using it + + from ..common.cli import Tool, fail # or `from .cli import` if vendored + + 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 to stdout as plain print(). 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. + + 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 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."), +} + + +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.") + + +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): + args = self.parser.parse_args(argv) + try: + args.func(args) + 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)