updated conventions
This commit is contained in:
@@ -35,9 +35,22 @@ arrangement that already keeps `soleprint-ui` honest across `unt` and `mpr`.
|
|||||||
|
|
||||||
python ctrl/spr.py diff soleprint-cli soleprint/station/tools/histgen
|
python ctrl/spr.py diff soleprint-cli soleprint/station/tools/histgen
|
||||||
|
|
||||||
## Using it
|
## 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 # or `from .cli import` if vendored
|
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 = Tool("histgen", __doc__)
|
||||||
tool.command("scan", cmd_scan, "Read the repo and cache what was read.")
|
tool.command("scan", cmd_scan, "Read the repo and cache what was read.")
|
||||||
@@ -60,6 +73,7 @@ Conventions it encodes, so they stop being re-decided:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import importlib
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
# The flags worth having exactly one spelling of. A tool adds its own on top;
|
# The flags worth having exactly one spelling of. A tool adds its own on top;
|
||||||
@@ -102,6 +116,126 @@ def refuse_to_clobber(path, force, marker, what):
|
|||||||
"Pick another path, or pass --force to write into it anyway.")
|
"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):
|
def _prog(package, name):
|
||||||
"""
|
"""
|
||||||
How this tool was actually invoked, for the usage line.
|
How this tool was actually invoked, for the usage line.
|
||||||
|
|||||||
258
soleprint/station/tools/common/selftest.py
Normal file
258
soleprint/station/tools/common/selftest.py
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
"""
|
||||||
|
Prove the scaffold itself, before any tool is judged against it.
|
||||||
|
|
||||||
|
`histgen/selftest.py` proves a pipeline end to end. This proves the thing every
|
||||||
|
tool is about to depend on: the flag vocabulary, the one exit path, the clobber
|
||||||
|
guard, the lazy registry -- and that the vendored copies have not drifted from
|
||||||
|
the source.
|
||||||
|
|
||||||
|
The guards get the same treatment as the happy path, for the reason
|
||||||
|
`histgen/selftest.py` gives: the one real bug found while writing that file was
|
||||||
|
a guard that returned None on exactly the trees it was written for and reported
|
||||||
|
success anyway. Here that means checking that `fail()` produces no traceback and
|
||||||
|
that an unknown registry name is refused -- not just that a known one loads.
|
||||||
|
|
||||||
|
python3 -m station.tools.common.selftest # from soleprint/
|
||||||
|
python3 selftest.py # or from this directory
|
||||||
|
|
||||||
|
Stdlib only. Nothing is written outside a temp directory.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
TOOLS = HERE.parent
|
||||||
|
|
||||||
|
# Tools that vendor a copy of cli.py rather than importing it. Adding one here
|
||||||
|
# is how its copy starts being checked; see cli.py's docstring for why the two
|
||||||
|
# arrangements coexist.
|
||||||
|
VENDORS = ("histgen",)
|
||||||
|
|
||||||
|
|
||||||
|
def run_py(body, *, extra_path=None):
|
||||||
|
"""Run a snippet in a fresh interpreter, with station/tools on the path.
|
||||||
|
|
||||||
|
`cwd=TOOLS` is not tidiness. Python prepends the working directory to
|
||||||
|
sys.path ahead of PYTHONPATH, and **`soleprint/common/` already exists** --
|
||||||
|
it holds auth, theme and ui. So the same snippet run from `soleprint/`
|
||||||
|
imports that package instead of this one and dies with
|
||||||
|
"No module named 'common.cli'". Pinning cwd here removes the ambiguity from
|
||||||
|
the harness; `shadowing` below is the check that keeps the hazard visible.
|
||||||
|
"""
|
||||||
|
path = str(TOOLS) + (f":{extra_path}" if extra_path else "")
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, "-c", body], cwd=str(TOOLS),
|
||||||
|
capture_output=True, text=True,
|
||||||
|
env={"PYTHONPATH": path, "PATH": "/usr/bin:/bin", "HOME": str(Path.home())},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def code_only(path):
|
||||||
|
"""A file's AST with the module docstring dropped.
|
||||||
|
|
||||||
|
The vendored copies are *meant* to carry a different module docstring -- the
|
||||||
|
promoted copy explains where it lives and why it lives in two places, which
|
||||||
|
would be wrong in a folder that has been copied out. Everything below the
|
||||||
|
docstring must match exactly, so that is what gets compared. A plain
|
||||||
|
`diff -rq`, which is what `ctrl/spr.py diff` runs, cannot express this: it
|
||||||
|
is directory-to-directory and whole-file, so it reports drift forever on a
|
||||||
|
difference that is correct.
|
||||||
|
"""
|
||||||
|
tree = ast.parse(Path(path).read_text(encoding="utf-8"))
|
||||||
|
if (tree.body and isinstance(tree.body[0], ast.Expr)
|
||||||
|
and isinstance(tree.body[0].value, ast.Constant)
|
||||||
|
and isinstance(tree.body[0].value.value, str)):
|
||||||
|
tree.body = tree.body[1:]
|
||||||
|
return ast.dump(tree, include_attributes=False)
|
||||||
|
|
||||||
|
|
||||||
|
REGISTRY_SETUP = """
|
||||||
|
import sys
|
||||||
|
from common.cli import Registry
|
||||||
|
R = Registry({"pydantic": ".pydantic:PydanticGenerator",
|
||||||
|
"typescript": ".typescript:TypeScriptGenerator"},
|
||||||
|
package="modelgen.generator", what="generator",
|
||||||
|
aliases={"ts": "typescript"})
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
failures = []
|
||||||
|
|
||||||
|
def check(label, condition, detail=""):
|
||||||
|
print(f" {'ok ' if condition else 'FAIL'} {label}")
|
||||||
|
if not condition:
|
||||||
|
failures.append(f"{label}{': ' + detail if detail else ''}")
|
||||||
|
|
||||||
|
sys.path.insert(0, str(TOOLS))
|
||||||
|
from common.cli import COMMON, Registry, fail, refuse_to_clobber # noqa: F401
|
||||||
|
|
||||||
|
# ── the flag vocabulary ──────────────────────────────────────────
|
||||||
|
check("COMMON carries exactly the five shared flags",
|
||||||
|
sorted(COMMON) == ["dry_run", "force", "name", "output", "source"],
|
||||||
|
f"got {sorted(COMMON)}")
|
||||||
|
check("--output is the canonical spelling, not --out",
|
||||||
|
COMMON["output"]["flags"] == ("--output", "-o"))
|
||||||
|
check("--source/-s, --name/-n, --force/-f agree with the docstring",
|
||||||
|
COMMON["source"]["flags"] == ("--source", "-s")
|
||||||
|
and COMMON["name"]["flags"] == ("--name", "-n")
|
||||||
|
and COMMON["force"]["flags"] == ("--force", "-f"))
|
||||||
|
check("--dry-run is long-only", COMMON["dry_run"]["flags"] == ("--dry-run",))
|
||||||
|
|
||||||
|
# ── fail() is the only exit path ─────────────────────────────────
|
||||||
|
r = run_py("from common.cli import fail; fail('the message', 'the hint')")
|
||||||
|
check("fail: exits 1", r.returncode == 1, f"exit {r.returncode}")
|
||||||
|
check("fail: writes to stderr, never stdout",
|
||||||
|
r.stderr.startswith("Error: the message") and not r.stdout)
|
||||||
|
check("fail: the hint is carried", "the hint" in r.stderr)
|
||||||
|
check("fail: no traceback", "Traceback" not in r.stderr, r.stderr)
|
||||||
|
|
||||||
|
# ── Ctrl-C and broken pipes are decisions, not crashes ───────────
|
||||||
|
r = run_py("from common.cli import Tool\n"
|
||||||
|
"def boom(a): raise KeyboardInterrupt\n"
|
||||||
|
"t = Tool('x', 'd'); t.command('go', boom, 'h'); t.run(['go'])")
|
||||||
|
check("Tool.run: KeyboardInterrupt -> exit 130, no traceback",
|
||||||
|
r.returncode == 130 and "Traceback" not in r.stderr, r.stderr)
|
||||||
|
r = run_py("from common.cli import Tool\n"
|
||||||
|
"def boom(a): raise BrokenPipeError\n"
|
||||||
|
"t = Tool('x', 'd'); t.command('go', boom, 'h'); t.run(['go'])")
|
||||||
|
check("Tool.run: BrokenPipeError -> exit 0", r.returncode == 0)
|
||||||
|
|
||||||
|
# ── a bare invocation must not report success ────────────────────
|
||||||
|
r = run_py("from common.cli import Tool\n"
|
||||||
|
"t = Tool('x', 'd'); t.command('go', lambda a: None, 'h'); t.run([])")
|
||||||
|
check("bare invocation: non-zero exit", r.returncode != 0, f"exit {r.returncode}")
|
||||||
|
check("bare invocation: usage on stderr", "usage:" in r.stderr and not r.stdout)
|
||||||
|
|
||||||
|
# ── --help is instant and touches nothing ────────────────────────
|
||||||
|
r = run_py("from common.cli import Tool\n"
|
||||||
|
"t = Tool('x', 'd', package='pkg'); t.command('go', lambda a: None, 'h')\n"
|
||||||
|
"t.run(['--help'])")
|
||||||
|
check("--help: exit 0", r.returncode == 0)
|
||||||
|
check("--help: the usage line names the package, not __main__.py",
|
||||||
|
"python -m pkg" in r.stdout, r.stdout.splitlines()[:1])
|
||||||
|
|
||||||
|
# ── refuse_to_clobber ────────────────────────────────────────────
|
||||||
|
with tempfile.TemporaryDirectory(prefix="common-selftest-") as tmp:
|
||||||
|
tmp = Path(tmp)
|
||||||
|
missing, empty, ours, theirs = (tmp / n for n in
|
||||||
|
("missing", "empty", "ours", "theirs"))
|
||||||
|
empty.mkdir()
|
||||||
|
ours.mkdir(); (ours / ".marker").write_text("")
|
||||||
|
theirs.mkdir(); (theirs / "handwritten.txt").write_text("do not eat me")
|
||||||
|
|
||||||
|
def clobbers(path, force=False):
|
||||||
|
r = run_py(
|
||||||
|
"from pathlib import Path\n"
|
||||||
|
"from common.cli import refuse_to_clobber\n"
|
||||||
|
f"refuse_to_clobber(Path({str(path)!r}), {force}, '.marker', 'the tool')")
|
||||||
|
return r.returncode, r.stderr
|
||||||
|
|
||||||
|
check("clobber: a path that does not exist is fine", clobbers(missing)[0] == 0)
|
||||||
|
check("clobber: an empty directory is fine", clobbers(empty)[0] == 0)
|
||||||
|
check("clobber: our own output is fine", clobbers(ours)[0] == 0)
|
||||||
|
rc, err = clobbers(theirs)
|
||||||
|
check("clobber: a foreign non-empty directory is refused", rc == 1)
|
||||||
|
check("clobber: refusal says what to do instead",
|
||||||
|
"--force" in err and "Traceback" not in err, err)
|
||||||
|
check("clobber: --force writes into it anyway", clobbers(theirs, True)[0] == 0)
|
||||||
|
|
||||||
|
# ── the lazy registry ────────────────────────────────────────────
|
||||||
|
r = run_py(REGISTRY_SETUP + """
|
||||||
|
before = set(sys.modules)
|
||||||
|
R.names(); R.choices(); R.help("One of:")
|
||||||
|
leaked = set(sys.modules) - before
|
||||||
|
print("LEAKED", len(leaked))
|
||||||
|
""")
|
||||||
|
check("registry: listing imports nothing",
|
||||||
|
"LEAKED 0" in r.stdout, r.stdout + r.stderr)
|
||||||
|
|
||||||
|
check("registry: names() lists each implementation once",
|
||||||
|
Registry({"a": ".m:A", "b": ".m:B"}, aliases={"c": "b"}).names() == ["a", "b"])
|
||||||
|
check("registry: choices() accepts every spelling",
|
||||||
|
Registry({"a": ".m:A", "b": ".m:B"}, aliases={"c": "b"}).choices()
|
||||||
|
== ["a", "b", "c"])
|
||||||
|
check("registry: help() marks aliases without importing",
|
||||||
|
Registry({"b": ".m:B"}, aliases={"c": "b"}).help() == "b (c)")
|
||||||
|
check("registry: resolve() follows an alias",
|
||||||
|
Registry({"b": ".m:B"}, aliases={"c": "b"}).resolve("c") == "b")
|
||||||
|
|
||||||
|
try:
|
||||||
|
Registry({"a": ".m:A"}, aliases={"bad": "nothere"})
|
||||||
|
dangling = False
|
||||||
|
except ValueError:
|
||||||
|
dangling = True
|
||||||
|
check("registry: a dangling alias is refused at construction", dangling)
|
||||||
|
|
||||||
|
for label, snippet, want in (
|
||||||
|
("an unknown name", 'R.load("nope")', "unknown generator"),
|
||||||
|
("a malformed entry",
|
||||||
|
'Registry({"a": "no_colon"}, what="thing").load("a")', "malformed"),
|
||||||
|
("a missing module",
|
||||||
|
'Registry({"a": ".nope:T"}, package="modelgen.generator").load("a")',
|
||||||
|
"could not be loaded"),
|
||||||
|
("a missing attribute",
|
||||||
|
'Registry({"a": ".base:NoSuchAttr"}, package="modelgen.generator").load("a")',
|
||||||
|
"NoSuchAttr"),
|
||||||
|
):
|
||||||
|
r = run_py(REGISTRY_SETUP + snippet)
|
||||||
|
check(f"registry: {label} -> Error on stderr, exit 1, no traceback",
|
||||||
|
r.returncode == 1 and r.stderr.startswith("Error:")
|
||||||
|
and want in r.stderr and "Traceback" not in r.stderr,
|
||||||
|
r.stderr.strip())
|
||||||
|
|
||||||
|
# ── the name `common` is taken one level up ─────────────────────
|
||||||
|
# Not hypothetical: soleprint/common/ holds auth, theme and ui. Tools must
|
||||||
|
# reach the scaffold relatively (`from ..common.cli import Tool`) or by its
|
||||||
|
# full path (`station.tools.common.cli`) -- never as top-level `common.cli`,
|
||||||
|
# which resolves to the wrong package from soleprint/.
|
||||||
|
shadow = TOOLS.parent.parent / "common"
|
||||||
|
check("shadowing: soleprint/common/ exists, so top-level `common` is ambiguous",
|
||||||
|
shadow.is_dir(),
|
||||||
|
"if this ever fails the hazard is gone and this check can go with it")
|
||||||
|
r = subprocess.run(
|
||||||
|
[sys.executable, "-c", "import common.cli"], cwd=str(shadow.parent),
|
||||||
|
capture_output=True, text=True,
|
||||||
|
env={"PYTHONPATH": str(TOOLS), "PATH": "/usr/bin:/bin", "HOME": str(Path.home())})
|
||||||
|
check("shadowing: `import common.cli` from soleprint/ picks the wrong package",
|
||||||
|
r.returncode != 0 and "common.cli" in r.stderr,
|
||||||
|
"it resolved -- re-read the import advice in cli.py's docstring")
|
||||||
|
r = subprocess.run(
|
||||||
|
[sys.executable, "-c", "import station.tools.common.cli as c; print(c.__file__)"],
|
||||||
|
cwd=str(shadow.parent), capture_output=True, text=True,
|
||||||
|
env={"PATH": "/usr/bin:/bin", "HOME": str(Path.home())})
|
||||||
|
check("shadowing: the full path resolves correctly from soleprint/",
|
||||||
|
r.returncode == 0 and "station/tools/common/cli.py" in r.stdout,
|
||||||
|
(r.stdout + r.stderr).strip())
|
||||||
|
|
||||||
|
# ── vendored copies have not drifted ─────────────────────────────
|
||||||
|
source = code_only(HERE / "cli.py")
|
||||||
|
for tool in VENDORS:
|
||||||
|
copy = TOOLS / tool / "cli.py"
|
||||||
|
if not copy.exists():
|
||||||
|
check(f"vendor: {tool}/cli.py exists", False, f"{copy} is missing")
|
||||||
|
continue
|
||||||
|
check(f"vendor: {tool}/cli.py matches common/cli.py below the docstring",
|
||||||
|
code_only(copy) == source,
|
||||||
|
"re-vendor it: keep its docstring, take common/cli.py's code")
|
||||||
|
check(f"vendor: {tool}/cli.py keeps its own docstring",
|
||||||
|
ast.get_docstring(ast.parse(copy.read_text(encoding='utf-8')))
|
||||||
|
!= ast.get_docstring(ast.parse((HERE / 'cli.py').read_text(encoding='utf-8'))),
|
||||||
|
"a vendored copy describing its own promotion is wrong in a copied-out folder")
|
||||||
|
|
||||||
|
print()
|
||||||
|
if failures:
|
||||||
|
print(f"{len(failures)} check(s) failed:")
|
||||||
|
for f in failures:
|
||||||
|
print(f" - {f}")
|
||||||
|
return 1
|
||||||
|
print("all checks passed")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -10,12 +10,15 @@ The expensive half (reading the code) and the mechanical half (applying a plan)
|
|||||||
are split on purpose, and `plan.json` is the seam. Everything before it is
|
are split on purpose, and `plan.json` is the seam. Everything before it is
|
||||||
analysis and can be cached; everything after it is git commands that fail fast.
|
analysis and can be cached; everything after it is git commands that fail fast.
|
||||||
|
|
||||||
python -m station.tools.histgen scan /path/to/repo
|
python -m station.tools.histgen scan --source /path/to/repo --output /path/to/out
|
||||||
python -m station.tools.histgen plan /path/to/repo
|
python -m station.tools.histgen plan --source /path/to/repo --output /path/to/out
|
||||||
python -m station.tools.histgen brief /path/to/repo
|
python -m station.tools.histgen brief --source /path/to/repo --output /path/to/out
|
||||||
# an agent reads briefs/ and writes title+body back into plan.json
|
# an agent reads briefs/ and writes title+body back into plan.json
|
||||||
python -m station.tools.histgen apply /path/to/repo --dry-run
|
python -m station.tools.histgen export --source /path/to/repo --output /path/to/out
|
||||||
python -m station.tools.histgen verify /path/to/repo
|
python -m station.tools.histgen verify --source /path/to/repo --output /path/to/out
|
||||||
|
|
||||||
|
Set both paths once with `config --init` and the verbs take no arguments at all.
|
||||||
|
`--output/-o` is the canonical spelling; `--out` still works and is hidden.
|
||||||
|
|
||||||
Stdlib only, no network. The directory can be copied out of soleprint and run
|
Stdlib only, no network. 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
|
on its own — a repo that needs a history seeded is, by definition, not one that
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Histgen CLI — seed a clean, logical history into a repo.
|
Histgen CLI — seed a clean, logical history into a repo.
|
||||||
|
|
||||||
python -m station.tools.histgen run --source ~/work/thing --out ~/out
|
python -m station.tools.histgen run --source ~/work/thing --output ~/out
|
||||||
python -m station.tools.histgen list # the commits, to confirm
|
python -m station.tools.histgen list # the commits, to confirm
|
||||||
python -m station.tools.histgen export # write them into out/thing
|
python -m station.tools.histgen export # write them into out/thing
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ Run from the soleprint/ directory so `station.tools...` resolves, or copy the
|
|||||||
folder out and use its Makefile.
|
folder out and use its Makefile.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -259,10 +260,28 @@ def main(argv=None):
|
|||||||
|
|
||||||
for verb, help_text in VERBS:
|
for verb, help_text in VERBS:
|
||||||
tool.command(verb, handlers[verb], help_text)
|
tool.command(verb, handlers[verb], help_text)
|
||||||
tool.argument(verb, "--source", "-s", default=None, metavar="DIR",
|
# The spellings come from COMMON so they are decided in one place
|
||||||
help="The tree to read. Never written to.")
|
# (cli.py:67). This tool used to register --out/-o against a scaffold
|
||||||
tool.argument(verb, "--out", "-o", default=None, metavar="DIR",
|
# whose canonical spelling is --output/-o: the scaffold's only user
|
||||||
help="Where the plan and the exported repo go.")
|
# contradicting the standard it exists to set.
|
||||||
|
#
|
||||||
|
# dest="out" is deliberate and is NOT the same question. The config
|
||||||
|
# file's key is "out" (config.py:46 KEYS) and config.py:123 reads
|
||||||
|
# getattr(args, "out"). That is a file format; renaming it would break
|
||||||
|
# every histgen.json already on disk, and it is not what C2 asks for.
|
||||||
|
# The command line is what gets standardised here.
|
||||||
|
tool.common(
|
||||||
|
verb, "source", "output",
|
||||||
|
source=dict(default=None, metavar="DIR",
|
||||||
|
help="The tree to read. Never written to."),
|
||||||
|
output=dict(dest="out", default=None, metavar="DIR",
|
||||||
|
help="Where the plan and the exported repo go."),
|
||||||
|
)
|
||||||
|
# --out stays, hidden. Not politeness: histgen/Makefile:49 builds
|
||||||
|
# `--out $(OUT)` and selftest.py passes it in 14 places, so dropping it
|
||||||
|
# would break the tool's own test suite.
|
||||||
|
tool.argument(verb, "--out", dest="out", default=None, metavar="DIR",
|
||||||
|
help=argparse.SUPPRESS)
|
||||||
tool.argument(verb, "--config", "-c", default=None, metavar="FILE",
|
tool.argument(verb, "--config", "-c", default=None, metavar="FILE",
|
||||||
help="Settings file, instead of histgen.json beside the tool.")
|
help="Settings file, instead of histgen.json beside the tool.")
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ Conventions it encodes, so they stop being re-decided:
|
|||||||
`--help` stays instant and an optional dependency only costs the one
|
`--help` stays instant and an optional dependency only costs the one
|
||||||
subcommand that needs it.
|
subcommand that needs it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import importlib
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
# The flags worth having exactly one spelling of. A tool adds its own on top;
|
# The flags worth having exactly one spelling of. A tool adds its own on top;
|
||||||
@@ -76,6 +76,126 @@ def refuse_to_clobber(path, force, marker, what):
|
|||||||
"Pick another path, or pass --force to write into it anyway.")
|
"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):
|
def _prog(package, name):
|
||||||
"""
|
"""
|
||||||
How this tool was actually invoked, for the usage line.
|
How this tool was actually invoked, for the usage line.
|
||||||
|
|||||||
Reference in New Issue
Block a user