Skip to content

Commit 0e432fa

Browse files
authored
Merge pull request #807 from tiran/cli-entry-points
feat: load CLI commands from entry points
2 parents 34f78d4 + bff697c commit 0e432fa

File tree

4 files changed

+118
-41
lines changed

4 files changed

+118
-41
lines changed

docs/customization.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,3 +445,30 @@ def post_bootstrap(
445445
f"{req.name}: running post bootstrap hook for {sdist_filename} and {wheel_filename}"
446446
)
447447
```
448+
449+
## Custom CLI (command line interface) commands
450+
451+
Fromager's CLI can be extended with additional commands with entry point
452+
group `fromager.cli`. The entry point value must return a valid `click`
453+
command or command group. The name must match the command name.
454+
455+
```yaml
456+
# pyproject.toml
457+
[project.entry-points."fromager.cli"]
458+
mycommand = "mypackage.module:mycommand"
459+
```
460+
461+
```python
462+
# mypackage/module.py
463+
import click
464+
from fromager import context
465+
466+
@click.command()
467+
@click.argument("example")
468+
@click.pass_obj
469+
def mycommand(
470+
wkctx: context.WorkContext,
471+
example: str,
472+
) -> None:
473+
...
474+
```

pyproject.toml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,27 @@ resolve_source = "fromager.sources:default_resolve_source"
101101
build_sdist = "fromager.sources:default_build_sdist"
102102
build_wheel = "fromager.wheels:default_build_wheel"
103103

104+
[project.entry-points."fromager.cli"]
105+
bootstrap = "fromager.commands.bootstrap:bootstrap"
106+
bootstrap-parallel = "fromager.commands.bootstrap:bootstrap_parallel"
107+
build = "fromager.commands.build:build"
108+
build-sequence = "fromager.commands.build:build_sequence"
109+
build-parallel = "fromager.commands.build:build_parallel"
110+
build-order = "fromager.commands.build_order:build_order"
111+
find-updates = "fromager.commands.find_updates:find_updates"
112+
graph = "fromager.commands.graph:graph"
113+
lint = "fromager.commands.lint:lint"
114+
list-overrides = "fromager.commands.list_overrides:list_overrides"
115+
list-versions = "fromager.commands.list_versions:list_versions"
116+
migrate-config = "fromager.commands.migrate_config:migrate_config"
117+
minimize = "fromager.commands.minimize:minimize"
118+
stats = "fromager.commands.stats:stats"
119+
step = "fromager.commands.step:step"
120+
canonicalize = "fromager.commands.canonicalize:canonicalize"
121+
download-sequence = "fromager.commands.download_sequence:download_sequence"
122+
wheel-server = "fromager.commands.server:wheel_server"
123+
lint-requirements = "fromager.commands.lint_requirements:lint_requirements"
124+
104125
[tool.coverage.run]
105126
branch = true
106127
parallel = true

src/fromager/commands/__init__.py

Lines changed: 41 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,41 @@
1-
from . import (
2-
bootstrap,
3-
build,
4-
build_order,
5-
canonicalize,
6-
download_sequence,
7-
find_updates,
8-
graph,
9-
lint,
10-
lint_requirements,
11-
list_overrides,
12-
list_versions,
13-
migrate_config,
14-
minimize,
15-
server,
16-
stats,
17-
step,
18-
)
19-
20-
commands = [
21-
bootstrap.bootstrap,
22-
bootstrap.bootstrap_parallel,
23-
build.build,
24-
build.build_sequence,
25-
build.build_parallel,
26-
build_order.build_order,
27-
find_updates.find_updates,
28-
graph.graph,
29-
lint.lint,
30-
list_overrides.list_overrides,
31-
list_versions.list_versions,
32-
migrate_config.migrate_config,
33-
minimize.minimize,
34-
stats.stats,
35-
step.step,
36-
canonicalize.canonicalize,
37-
download_sequence.download_sequence,
38-
server.wheel_server,
39-
migrate_config.migrate_config,
40-
lint_requirements.lint_requirements,
41-
]
1+
import importlib.metadata
2+
3+
import click
4+
5+
6+
def _load_commands() -> list[click.Command]:
7+
"""Load commands from fromager.cli entry points"""
8+
commands: list[click.Command] = []
9+
seen: dict[str, str] = {}
10+
11+
for ep in importlib.metadata.entry_points(group="fromager.cli"):
12+
try:
13+
command: click.Command | object = ep.load()
14+
except Exception as e:
15+
raise RuntimeError(
16+
f"Unable to load 'fromager.cli' entry point {ep.value!r}"
17+
) from e
18+
19+
# target must be a click command
20+
if not isinstance(command, click.Command):
21+
raise RuntimeError(f"{ep.value!r} is not a click.Command: {command}")
22+
23+
# command name and entry point name have to match
24+
if command.name != ep.name:
25+
raise ValueError(
26+
f"Command name {command.name!r} does not match entry "
27+
f"point name {ep.name!r} for {ep.value!r}"
28+
)
29+
30+
# third party commands can conflict
31+
if command.name in seen:
32+
raise ValueError(
33+
f"Conflict: {ep.value!r} and {seen[ep.name]!r} define {ep.name!r}"
34+
)
35+
commands.append(command)
36+
seen[ep.name] = ep.value
37+
38+
return commands
39+
40+
41+
commands = _load_commands()

tests/test_cli.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from click.testing import CliRunner
44

55
from fromager.__main__ import main as fromager
6+
from fromager.commands import commands
67

78

89
def test_migrate_config(
@@ -39,3 +40,31 @@ def test_fromager_version(cli_runner: CliRunner) -> None:
3940
result = cli_runner.invoke(fromager, ["--version"])
4041
assert result.exit_code == 0
4142
assert result.stdout.startswith("fromager, version")
43+
44+
45+
KNOWN_COMMANDS: set[str] = {
46+
"bootstrap",
47+
"bootstrap-parallel",
48+
"build",
49+
"build-order",
50+
"build-parallel",
51+
"build-sequence",
52+
"canonicalize",
53+
"download-sequence",
54+
"find-updates",
55+
"graph",
56+
"lint",
57+
"lint-requirements",
58+
"list-overrides",
59+
"list-versions",
60+
"migrate-config",
61+
"minimize",
62+
"stats",
63+
"step",
64+
"wheel-server",
65+
}
66+
67+
68+
def test_registered_eps() -> None:
69+
registered = {c.name for c in commands}
70+
assert registered == KNOWN_COMMANDS

0 commit comments

Comments
 (0)