Skip to content

Commit ee3de78

Browse files
committed
✨ Add plugin system via fastapi_cli.plugins entry point group
1 parent 9b55c29 commit ee3de78

6 files changed

Lines changed: 167 additions & 15 deletions

File tree

src/fastapi_cli/cli.py

Lines changed: 39 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
from importlib.metadata import entry_points as _entry_points
23
from pathlib import Path
34
from typing import Annotated, Any
45

@@ -15,9 +16,7 @@
1516
from .logging import setup_logging
1617
from .utils.cli import get_rich_toolkit, get_uvicorn_log_config
1718

18-
app = typer.Typer(
19-
rich_markup_mode="rich", context_settings={"help_option_names": ["-h", "--help"]}
20-
)
19+
app = typer.Typer(rich_markup_mode="rich", context_settings={"help_option_names": ["-h", "--help"]})
2120

2221
logger = logging.getLogger(__name__)
2322

@@ -48,6 +47,39 @@
4847
pass
4948

5049

50+
def _cmd_name(cmd_info: Any) -> str | None:
51+
"""Return the effective CLI name for a registered Typer command."""
52+
if cmd_info.name is not None:
53+
return cmd_info.name
54+
if cmd_info.callback is not None:
55+
return cmd_info.callback.__name__.lower().replace("_", "-")
56+
return None
57+
58+
59+
def _load_plugins(typer_app: typer.Typer) -> None:
60+
"""Load commands registered via the 'fastapi_cli.plugins' entry point group."""
61+
known: set[str] = {n for ci in typer_app.registered_commands if (n := _cmd_name(ci))}
62+
for ep in _entry_points(group="fastapi_cli.plugins"):
63+
cursor = len(typer_app.registered_commands)
64+
try:
65+
ep.load()(typer_app)
66+
except Exception as e:
67+
logger.warning("Plugin '%s' failed to load: %s", ep.name, e)
68+
continue
69+
collisions = {
70+
n
71+
for ci in typer_app.registered_commands[cursor:]
72+
if (n := _cmd_name(ci)) and n in known
73+
}
74+
if collisions:
75+
logger.warning(
76+
"Plugin '%s' overrides existing command(s): %s",
77+
ep.name,
78+
", ".join(sorted(collisions)),
79+
)
80+
known.update(n for ci in typer_app.registered_commands[cursor:] if (n := _cmd_name(ci)))
81+
82+
5183
def version_callback(value: bool) -> None:
5284
if value:
5385
print(f"FastAPI CLI version: [green]{__version__}[/green]")
@@ -58,9 +90,7 @@ def version_callback(value: bool) -> None:
5890
def callback(
5991
version: Annotated[
6092
bool | None,
61-
typer.Option(
62-
"--version", help="Show the version and exit.", callback=version_callback
63-
),
93+
typer.Option("--version", help="Show the version and exit.", callback=version_callback),
6494
] = None,
6595
verbose: bool = typer.Option(False, help="Enable verbose output"),
6696
) -> None:
@@ -88,9 +118,7 @@ def _get_module_tree(module_paths: list[Path]) -> Tree:
88118

89119
tree = root_tree
90120
for sub_path in module_paths[1:]:
91-
sub_name = (
92-
f"🐍 {sub_path.name}" if sub_path.is_file() else f"📁 {sub_path.name}"
93-
)
121+
sub_name = f"🐍 {sub_path.name}" if sub_path.is_file() else f"📁 {sub_path.name}"
94122
tree = tree.add(sub_name)
95123
if sub_path.is_dir():
96124
tree.add("[dim]🐍 __init__.py[/dim]")
@@ -125,9 +153,7 @@ def _run(
125153

126154
if entrypoint and (path or app):
127155
toolkit.print_line()
128-
toolkit.print(
129-
"[error]Cannot use --entrypoint together with path or --app arguments"
130-
)
156+
toolkit.print("[error]Cannot use --entrypoint together with path or --app arguments")
131157
toolkit.print_line()
132158
raise typer.Exit(code=1)
133159

@@ -221,9 +247,7 @@ def _run(
221247
port=port,
222248
reload=reload,
223249
reload_dirs=(
224-
[str(directory.resolve()) for directory in reload_dirs]
225-
if reload_dirs
226-
else None
250+
[str(directory.resolve()) for directory in reload_dirs] if reload_dirs else None
227251
),
228252
workers=workers,
229253
root_path=root_path,

tests/assets/plugins/__init__.py

Whitespace-only changes.

tests/assets/plugins/broken.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import typer
2+
3+
4+
def register(app: typer.Typer) -> None:
5+
raise RuntimeError("intentionally broken plugin")

tests/assets/plugins/colliding.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import typer
2+
3+
4+
def register(app: typer.Typer) -> None:
5+
@app.command("dev") # collides with built-in dev command
6+
def dev() -> None:
7+
pass # pragma: no cover

tests/assets/plugins/sample.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import typer
2+
3+
4+
def register(app: typer.Typer) -> None:
5+
@app.command("ping")
6+
def ping() -> None:
7+
"""Test command added by plugin."""
8+
typer.echo("pong") # pragma: no cover

tests/test_cli_plugin.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import sys
2+
from collections.abc import Generator
3+
from importlib.metadata import EntryPoint
4+
from pathlib import Path
5+
from unittest.mock import patch
6+
7+
import pytest
8+
import typer as _typer
9+
from fastapi_cli.cli import _load_plugins
10+
11+
PLUGINS_ASSET_PATH: Path = Path(__file__).parent / "assets"
12+
13+
14+
@pytest.fixture
15+
def test_app() -> _typer.Typer:
16+
return _typer.Typer()
17+
18+
19+
@pytest.fixture
20+
def plugins_on_path() -> Generator[None, None, None]:
21+
original_path = sys.path.copy()
22+
sys.path.insert(0, str(PLUGINS_ASSET_PATH))
23+
try:
24+
yield
25+
finally:
26+
sys.path[:] = original_path
27+
for key in list(sys.modules.keys()):
28+
if key.startswith("plugins."):
29+
del sys.modules[key]
30+
31+
32+
def _entry_point(name: str, module_attr: str) -> EntryPoint:
33+
return EntryPoint(
34+
name=name,
35+
value=f"plugins.{module_attr}",
36+
group="fastapi_cli.plugins",
37+
)
38+
39+
40+
def test_load_plugins_happy_path(plugins_on_path: None, test_app: _typer.Typer) -> None:
41+
"""Plugin registers its command on the Typer app."""
42+
43+
ep = _entry_point("sample", "sample:register")
44+
45+
with patch("fastapi_cli.cli._entry_points", return_value=[ep]):
46+
_load_plugins(test_app)
47+
48+
names = {ci.name for ci in test_app.registered_commands}
49+
assert "ping" in names
50+
51+
52+
def test_load_plugins_logs_on_failure(plugins_on_path: None, test_app: _typer.Typer) -> None:
53+
"""A plugin that raises is skipped and a warning is logged."""
54+
55+
ep = _entry_point("broken", "broken:register")
56+
57+
with (
58+
patch("fastapi_cli.cli._entry_points", return_value=[ep]),
59+
patch("fastapi_cli.cli.logger") as mock_logger,
60+
):
61+
_load_plugins(test_app)
62+
63+
mock_logger.warning.assert_called_once()
64+
_fmt, ep_name, *_ = mock_logger.warning.call_args.args
65+
assert "broken" in ep_name
66+
67+
68+
def test_load_plugins_warns_on_collision_with_builtin(
69+
plugins_on_path: None, test_app: _typer.Typer
70+
) -> None:
71+
"""Plugin registering a name already on the app triggers a collision warning."""
72+
73+
@test_app.command("dev")
74+
def existing() -> None:
75+
pass # pragma: no cover
76+
77+
ep = _entry_point("colliding", "colliding:register")
78+
79+
with (
80+
patch("fastapi_cli.cli._entry_points", return_value=[ep]),
81+
patch("fastapi_cli.cli.logger") as mock_logger,
82+
):
83+
_load_plugins(test_app)
84+
85+
mock_logger.warning.assert_called_once()
86+
_fmt, ep_name, collisions = mock_logger.warning.call_args.args
87+
assert "colliding" in ep_name
88+
assert "dev" in collisions
89+
90+
91+
def test_load_plugins_warns_on_cross_plugin_collision(
92+
plugins_on_path: None, test_app: _typer.Typer
93+
) -> None:
94+
"""Two plugins registering the same name: only the second gets a warning."""
95+
96+
ep_a = _entry_point("sample", "sample:register") # registers "ping"
97+
ep_b = _entry_point("colliding2", "sample:register") # also tries to register "ping"
98+
99+
with (
100+
patch("fastapi_cli.cli._entry_points", return_value=[ep_a, ep_b]),
101+
patch("fastapi_cli.cli.logger") as mock_logger,
102+
):
103+
_load_plugins(test_app)
104+
105+
assert mock_logger.warning.call_count == 1
106+
_fmt, ep_name, collisions = mock_logger.warning.call_args.args
107+
assert "colliding2" in ep_name
108+
assert "ping" in collisions

0 commit comments

Comments
 (0)