updated conventions

This commit is contained in:
2026-09-14 05:08:59 -03:00
parent 514efc5e52
commit 4fe3718795
5 changed files with 547 additions and 13 deletions

View File

@@ -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
## 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 # 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
from ..common.cli import Tool, fail # or `from .cli import` if vendored
`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.")
@@ -60,6 +73,7 @@ Conventions it encodes, so they stop being re-decided:
"""
import argparse
import importlib
import sys
# 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.")
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.

View 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())

View File

@@ -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
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 plan /path/to/repo
python -m station.tools.histgen brief /path/to/repo
python -m station.tools.histgen scan --source /path/to/repo --output /path/to/out
python -m station.tools.histgen plan --source /path/to/repo --output /path/to/out
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
python -m station.tools.histgen apply /path/to/repo --dry-run
python -m station.tools.histgen verify /path/to/repo
python -m station.tools.histgen export --source /path/to/repo --output /path/to/out
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
on its own — a repo that needs a history seeded is, by definition, not one that

View File

@@ -1,7 +1,7 @@
"""
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 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.
"""
import argparse
import sys
from pathlib import Path
@@ -259,10 +260,28 @@ def main(argv=None):
for verb, help_text in VERBS:
tool.command(verb, handlers[verb], help_text)
tool.argument(verb, "--source", "-s", default=None, metavar="DIR",
help="The tree to read. Never written to.")
tool.argument(verb, "--out", "-o", default=None, metavar="DIR",
help="Where the plan and the exported repo go.")
# The spellings come from COMMON so they are decided in one place
# (cli.py:67). This tool used to register --out/-o against a scaffold
# whose canonical spelling is --output/-o: the scaffold's only user
# 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",
help="Settings file, instead of histgen.json beside the tool.")

View File

@@ -32,8 +32,8 @@ Conventions it encodes, so they stop being re-decided:
`--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;
@@ -76,6 +76,126 @@ def refuse_to_clobber(path, force, marker, 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.