handover agent doing docgen-graphgen
This commit is contained in:
14
soleprint/station/tools/common/__init__.py
Normal file
14
soleprint/station/tools/common/__init__.py
Normal file
@@ -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"]
|
||||||
158
soleprint/station/tools/common/cli.py
Normal file
158
soleprint/station/tools/common/cli.py
Normal file
@@ -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 `<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
|
||||||
|
|
||||||
|
## 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)
|
||||||
Reference in New Issue
Block a user