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
|
||||
|
||||
## 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.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.
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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.")
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user