11import logging
2+ from importlib .metadata import entry_points as _entry_points
23from pathlib import Path
34from typing import Annotated , Any
45
56import typer
67from pydantic import ValidationError
78from rich import print
89from rich .tree import Tree
10+ from typer .models import CommandInfo
911
1012from fastapi_cli .config import FastAPIConfig
1113from fastapi_cli .discover import get_import_data , get_import_data_from_import_string
1517from .logging import setup_logging
1618from .utils .cli import get_rich_toolkit , get_uvicorn_log_config
1719
18- app = typer .Typer (
19- rich_markup_mode = "rich" , context_settings = {"help_option_names" : ["-h" , "--help" ]}
20- )
20+ app = typer .Typer (rich_markup_mode = "rich" , context_settings = {"help_option_names" : ["-h" , "--help" ]})
2121
2222logger = logging .getLogger (__name__ )
2323
4848 pass
4949
5050
51+ def _cmd_name (registered_command : CommandInfo ) -> Any :
52+ """Return the effective CLI name for a registered Typer command."""
53+ if registered_command .name is not None :
54+ return registered_command .name
55+ if registered_command .callback is not None :
56+ return registered_command .callback .__name__ .lower ().replace ("_" , "-" )
57+ return None
58+
59+
60+ def _load_cli_plugins (typer_app : typer .Typer ) -> None :
61+ """Load commands registered via the 'fastapi_cli.plugins' entry point group."""
62+
63+ # Seed with built-in command names so plugins overriding them get flagged.
64+ known : set [str ] = set ()
65+ for registered_command in typer_app .registered_commands :
66+ name = _cmd_name (registered_command )
67+ if name :
68+ known .add (name )
69+
70+ for entry_point in _entry_points (group = "fastapi_cli.plugins" ):
71+ # Snapshot length to slice off only what the plugin adds.
72+ cursor = len (typer_app .registered_commands )
73+ try :
74+ # resolves the `register` callable.
75+ entry_point .load ()(typer_app )
76+ except Exception as e :
77+ # Warning on broken plugin ans continue CLI execution.
78+ logger .warning ("Plugin '%s' failed to load: %s" , entry_point .name , e )
79+ continue
80+
81+ # Walk only plugin's new commands to detect collision.
82+ collisions : list [str ] = []
83+ for registered_command in typer_app .registered_commands [cursor :]:
84+ name = _cmd_name (registered_command )
85+ if not name :
86+ continue
87+ if name in known :
88+ collisions .append (name )
89+ known .add (name )
90+
91+ # One warning per plugin, listing all the names it overrode.
92+ if collisions :
93+ logger .warning (
94+ "Plugin '%s' overrides existing command(s): %s" ,
95+ entry_point .name ,
96+ ", " .join (sorted (collisions )),
97+ )
98+
99+
51100def version_callback (value : bool ) -> None :
52101 if value :
53102 print (f"FastAPI CLI version: [green]{ __version__ } [/green]" )
@@ -58,9 +107,7 @@ def version_callback(value: bool) -> None:
58107def callback (
59108 version : Annotated [
60109 bool | None ,
61- typer .Option (
62- "--version" , help = "Show the version and exit." , callback = version_callback
63- ),
110+ typer .Option ("--version" , help = "Show the version and exit." , callback = version_callback ),
64111 ] = None ,
65112 verbose : bool = typer .Option (False , help = "Enable verbose output" ),
66113) -> None :
@@ -88,9 +135,7 @@ def _get_module_tree(module_paths: list[Path]) -> Tree:
88135
89136 tree = root_tree
90137 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- )
138+ sub_name = f"🐍 { sub_path .name } " if sub_path .is_file () else f"📁 { sub_path .name } "
94139 tree = tree .add (sub_name )
95140 if sub_path .is_dir ():
96141 tree .add ("[dim]🐍 __init__.py[/dim]" )
@@ -125,9 +170,7 @@ def _run(
125170
126171 if entrypoint and (path or app ):
127172 toolkit .print_line ()
128- toolkit .print (
129- "[error]Cannot use --entrypoint together with path or --app arguments"
130- )
173+ toolkit .print ("[error]Cannot use --entrypoint together with path or --app arguments" )
131174 toolkit .print_line ()
132175 raise typer .Exit (code = 1 )
133176
@@ -221,9 +264,7 @@ def _run(
221264 port = port ,
222265 reload = reload ,
223266 reload_dirs = (
224- [str (directory .resolve ()) for directory in reload_dirs ]
225- if reload_dirs
226- else None
267+ [str (directory .resolve ()) for directory in reload_dirs ] if reload_dirs else None
227268 ),
228269 workers = workers ,
229270 root_path = root_path ,
@@ -448,4 +489,5 @@ def run(
448489
449490
450491def main () -> None :
492+ _load_cli_plugins (app )
451493 app ()
0 commit comments