diff --git a/src/fastapi_cli/cli.py b/src/fastapi_cli/cli.py index d5bcb8e..a806d43 100644 --- a/src/fastapi_cli/cli.py +++ b/src/fastapi_cli/cli.py @@ -60,9 +60,12 @@ def _run( command: str, app: Union[str, None] = None, proxy_headers: bool = False, + is_factory: bool = False, ) -> None: try: - use_uvicorn_app = get_import_string(path=path, app_name=app) + use_uvicorn_app = get_import_string( + path=path, app_name=app, is_factory=is_factory + ) except FastAPICLIException as e: logger.error(str(e)) raise typer.Exit(code=1) from None @@ -97,6 +100,7 @@ def _run( workers=workers, root_path=root_path, proxy_headers=proxy_headers, + factory=is_factory, ) @@ -105,7 +109,7 @@ def dev( path: Annotated[ Union[Path, None], typer.Argument( - help="A path to a Python file or package directory (with [blue]__init__.py[/blue] files) containing a [bold]FastAPI[/bold] app. If not provided, a default set of paths will be tried." + help="A path to a Python file or package directory (with [blue]__init__.py[/blue] files) containing a [bold]FastAPI[/bold] app or app factory. If not provided, a default set of paths will be tried." ), ] = None, *, @@ -145,6 +149,12 @@ def dev( help="Enable/Disable X-Forwarded-Proto, X-Forwarded-For, X-Forwarded-Port to populate remote address info." ), ] = True, + factory: Annotated[ + bool, + typer.Option( + help="Treat [bold]path[/bold] as an application factory, i.e. a () -> callable." + ), + ] = False, ) -> Any: """ Run a [bold]FastAPI[/bold] app in [yellow]development[/yellow] mode. ๐Ÿงช @@ -180,6 +190,7 @@ def dev( app=app, command="dev", proxy_headers=proxy_headers, + is_factory=factory, ) @@ -234,6 +245,12 @@ def run( help="Enable/Disable X-Forwarded-Proto, X-Forwarded-For, X-Forwarded-Port to populate remote address info." ), ] = True, + factory: Annotated[ + bool, + typer.Option( + help="Treat [bold]path[/bold] as an application factory, i.e. a () -> callable." + ), + ] = False, ) -> Any: """ Run a [bold]FastAPI[/bold] app in [green]production[/green] mode. ๐Ÿš€ @@ -270,6 +287,7 @@ def run( app=app, command="run", proxy_headers=proxy_headers, + is_factory=factory, ) diff --git a/src/fastapi_cli/discover.py b/src/fastapi_cli/discover.py index f442438..80dbd7b 100644 --- a/src/fastapi_cli/discover.py +++ b/src/fastapi_cli/discover.py @@ -98,7 +98,9 @@ def get_module_data_from_path(path: Path) -> ModuleData: ) -def get_app_name(*, mod_data: ModuleData, app_name: Union[str, None] = None) -> str: +def get_app_name( + *, mod_data: ModuleData, app_name: Union[str, None] = None, is_factory: bool = False +) -> str: try: mod = importlib.import_module(mod_data.module_import_str) except (ImportError, ValueError) as e: @@ -119,25 +121,37 @@ def get_app_name(*, mod_data: ModuleData, app_name: Union[str, None] = None) -> f"Could not find app name {app_name} in {mod_data.module_import_str}" ) app = getattr(mod, app_name) - if not isinstance(app, FastAPI): + if not isinstance(app, FastAPI) and not is_factory: raise FastAPICLIException( f"The app name {app_name} in {mod_data.module_import_str} doesn't seem to be a FastAPI app" ) + else: + if not callable(app) and is_factory: + raise FastAPICLIException( + f"The app factory {app_name} in {mod_data.module_import_str} doesn't seem to be a function" + ) return app_name for preferred_name in ["app", "api"]: if preferred_name in object_names_set: obj = getattr(mod, preferred_name) - if isinstance(obj, FastAPI): + if isinstance(obj, FastAPI) and not is_factory: return preferred_name for name in object_names: obj = getattr(mod, name) - if isinstance(obj, FastAPI): + if isinstance(obj, FastAPI) and not is_factory: + return name + elif callable(name) and is_factory: return name - raise FastAPICLIException("Could not find FastAPI app in module, try using --app") + raise FastAPICLIException( + "Could not find FastAPI app or app factory in module, try using --app" + ) def get_import_string( - *, path: Union[Path, None] = None, app_name: Union[str, None] = None + *, + path: Union[Path, None] = None, + app_name: Union[str, None] = None, + is_factory: bool = False, ) -> str: if not path: path = get_default_path() @@ -147,7 +161,9 @@ def get_import_string( raise FastAPICLIException(f"Path does not exist {path}") mod_data = get_module_data_from_path(path) sys.path.insert(0, str(mod_data.extra_sys_path)) - use_app_name = get_app_name(mod_data=mod_data, app_name=app_name) + use_app_name = get_app_name( + mod_data=mod_data, app_name=app_name, is_factory=is_factory + ) import_example = Syntax( f"from {mod_data.module_import_str} import {use_app_name}", "python" ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 44c14d2..58fe23d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -29,6 +29,7 @@ def test_dev() -> None: "workers": None, "root_path": "", "proxy_headers": True, + "factory": False, } assert "Using import string single_file_app:app" in result.output assert ( @@ -71,6 +72,7 @@ def test_dev_args() -> None: "workers": None, "root_path": "/api", "proxy_headers": False, + "factory": False, } assert "Using import string single_file_app:api" in result.output assert ( @@ -97,6 +99,7 @@ def test_run() -> None: "workers": None, "root_path": "", "proxy_headers": True, + "factory": False, } assert "Using import string single_file_app:app" in result.output assert ( @@ -141,6 +144,7 @@ def test_run_args() -> None: "workers": 2, "root_path": "/api", "proxy_headers": False, + "factory": False, } assert "Using import string single_file_app:api" in result.output assert ( diff --git a/tests/test_utils_package.py b/tests/test_utils_package.py index d5573db..60672bd 100644 --- a/tests/test_utils_package.py +++ b/tests/test_utils_package.py @@ -432,7 +432,8 @@ def test_package_dir_no_app() -> None: with pytest.raises(FastAPICLIException) as e: get_import_string(path=Path("package/core/utils.py")) assert ( - "Could not find FastAPI app in module, try using --app" in e.value.args[0] + "Could not find FastAPI app or app factory in module, try using --app" + in e.value.args[0] )