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