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 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 = 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.

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 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

View File

@@ -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.")

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 `--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.