From 4fe37187950dbac1572060e56c36f5429dcf61dd Mon Sep 17 00:00:00 2001 From: buenosairesam Date: Mon, 14 Sep 2026 05:08:59 -0300 Subject: [PATCH] updated conventions --- soleprint/station/tools/common/cli.py | 138 ++++++++++- soleprint/station/tools/common/selftest.py | 258 ++++++++++++++++++++ soleprint/station/tools/histgen/__init__.py | 13 +- soleprint/station/tools/histgen/__main__.py | 29 ++- soleprint/station/tools/histgen/cli.py | 122 ++++++++- 5 files changed, 547 insertions(+), 13 deletions(-) create mode 100644 soleprint/station/tools/common/selftest.py diff --git a/soleprint/station/tools/common/cli.py b/soleprint/station/tools/common/cli.py index c782052..1185a27 100644 --- a/soleprint/station/tools/common/cli.py +++ b/soleprint/station/tools/common/cli.py @@ -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. diff --git a/soleprint/station/tools/common/selftest.py b/soleprint/station/tools/common/selftest.py new file mode 100644 index 0000000..009b7a3 --- /dev/null +++ b/soleprint/station/tools/common/selftest.py @@ -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()) diff --git a/soleprint/station/tools/histgen/__init__.py b/soleprint/station/tools/histgen/__init__.py index ef9de7e..cf9047e 100644 --- a/soleprint/station/tools/histgen/__init__.py +++ b/soleprint/station/tools/histgen/__init__.py @@ -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 diff --git a/soleprint/station/tools/histgen/__main__.py b/soleprint/station/tools/histgen/__main__.py index a879928..b48fd5e 100644 --- a/soleprint/station/tools/histgen/__main__.py +++ b/soleprint/station/tools/histgen/__main__.py @@ -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.") diff --git a/soleprint/station/tools/histgen/cli.py b/soleprint/station/tools/histgen/cli.py index 1db03d7..f8c89e4 100644 --- a/soleprint/station/tools/histgen/cli.py +++ b/soleprint/station/tools/histgen/cli.py @@ -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.