From 9c0d9042689bfc455f90e1130258e545ef474c09 Mon Sep 17 00:00:00 2001 From: Patrick Arminio Date: Tue, 2 Sep 2025 11:53:46 +0100 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20Add=20support=20for=20passing?= =?UTF-8?q?=20apps=20as=20module:app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/fastapi_cli/cli.py | 33 +++++++++++++++++---- src/fastapi_cli/discover.py | 23 +++++++++++++++ tests/test_cli.py | 42 +++++++++++++++++++++++++++ tests/test_discover.py | 58 +++++++++++++++++++++++++++++++++++++ 4 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 tests/test_discover.py diff --git a/src/fastapi_cli/cli.py b/src/fastapi_cli/cli.py index b7b8736b..2293b0d0 100644 --- a/src/fastapi_cli/cli.py +++ b/src/fastapi_cli/cli.py @@ -7,7 +7,7 @@ from rich.tree import Tree from typing_extensions import Annotated -from fastapi_cli.discover import get_import_data +from fastapi_cli.discover import get_import_data, get_import_data_from_import_string from fastapi_cli.exceptions import FastAPICLIException from . import __version__ @@ -95,6 +95,7 @@ def _run( root_path: str = "", command: str, app: Union[str, None] = None, + entrypoint: Union[str, None] = None, proxy_headers: bool = False, forwarded_allow_ips: Union[str, None] = None, ) -> None: @@ -109,7 +110,10 @@ def _run( ) try: - import_data = get_import_data(path=path, app_name=app) + if entrypoint: + import_data = get_import_data_from_import_string(entrypoint) + else: + import_data = get_import_data(path=path, app_name=app) except FastAPICLIException as e: toolkit.print_line() toolkit.print(f"[error]{e}") @@ -124,10 +128,11 @@ def _run( toolkit.print(f"Importing from {module_data.extra_sys_path}") toolkit.print_line() - root_tree = _get_module_tree(module_data.module_paths) + if module_data.module_paths: + root_tree = _get_module_tree(module_data.module_paths) - toolkit.print(root_tree, tag="module") - toolkit.print_line() + toolkit.print(root_tree, tag="module") + toolkit.print_line() toolkit.print( "Importing the FastAPI app object from the module with the following code:", @@ -222,6 +227,14 @@ def dev( help="The name of the variable that contains the [bold]FastAPI[/bold] app in the imported module or package. If not provided, it is detected automatically." ), ] = None, + entrypoint: Annotated[ + Union[str, None], + typer.Option( + "--entrypoint", + "-e", + help="The FastAPI app import string in the format 'module:app_name'.", + ), + ] = None, proxy_headers: Annotated[ bool, typer.Option( @@ -267,6 +280,7 @@ def dev( reload=reload, root_path=root_path, app=app, + entrypoint=entrypoint, command="dev", proxy_headers=proxy_headers, forwarded_allow_ips=forwarded_allow_ips, @@ -318,6 +332,14 @@ def run( help="The name of the variable that contains the [bold]FastAPI[/bold] app in the imported module or package. If not provided, it is detected automatically." ), ] = None, + entrypoint: Annotated[ + Union[str, None], + typer.Option( + "--entrypoint", + "-e", + help="The FastAPI app import string in the format 'module:app_name'.", + ), + ] = None, proxy_headers: Annotated[ bool, typer.Option( @@ -364,6 +386,7 @@ def run( workers=workers, root_path=root_path, app=app, + entrypoint=entrypoint, command="run", proxy_headers=proxy_headers, forwarded_allow_ips=forwarded_allow_ips, diff --git a/src/fastapi_cli/discover.py b/src/fastapi_cli/discover.py index 43d0e9c9..b174f8fb 100644 --- a/src/fastapi_cli/discover.py +++ b/src/fastapi_cli/discover.py @@ -130,3 +130,26 @@ def get_import_data( return ImportData( app_name=use_app_name, module_data=mod_data, import_string=import_string ) + + +def get_import_data_from_import_string(import_string: str) -> ImportData: + module_str, _, app_name = import_string.partition(":") + + if not module_str or not app_name: + raise FastAPICLIException( + "Import string must be in the format module.submodule:app_name" + ) + + here = Path(".").resolve() + + sys.path.insert(0, str(here)) + + return ImportData( + app_name=app_name, + module_data=ModuleData( + module_import_str=module_str, + extra_sys_path=here, + module_paths=[], + ), + import_string=import_string, + ) diff --git a/tests/test_cli.py b/tests/test_cli.py index b6dc9671..7f23c845 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -285,6 +285,48 @@ def test_version() -> None: assert "FastAPI CLI version:" in result.output +def test_dev_with_import_string() -> None: + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke(app, ["dev", "--entrypoint", "single_file_app:api"]) + assert result.exit_code == 0, result.output + assert mock_run.called + assert mock_run.call_args + assert mock_run.call_args.kwargs == { + "app": "single_file_app:api", + "forwarded_allow_ips": None, + "host": "127.0.0.1", + "port": 8000, + "reload": True, + "workers": None, + "root_path": "", + "proxy_headers": True, + "log_config": get_uvicorn_log_config(), + } + assert "Using import string: single_file_app:api" in result.output + + +def test_run_with_import_string() -> None: + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke(app, ["run", "--entrypoint", "single_file_app:app"]) + assert result.exit_code == 0, result.output + assert mock_run.called + assert mock_run.call_args + assert mock_run.call_args.kwargs == { + "app": "single_file_app:app", + "forwarded_allow_ips": None, + "host": "0.0.0.0", + "port": 8000, + "reload": False, + "workers": None, + "root_path": "", + "proxy_headers": True, + "log_config": get_uvicorn_log_config(), + } + assert "Using import string: single_file_app:app" in result.output + + def test_script() -> None: result = subprocess.run( [sys.executable, "-m", "coverage", "run", "-m", "fastapi_cli", "--help"], diff --git a/tests/test_discover.py b/tests/test_discover.py new file mode 100644 index 00000000..b1052050 --- /dev/null +++ b/tests/test_discover.py @@ -0,0 +1,58 @@ +from pathlib import Path + +import pytest + +from fastapi_cli.discover import ( + ImportData, + get_import_data_from_import_string, +) +from fastapi_cli.exceptions import FastAPICLIException + +assets_path = Path(__file__).parent / "assets" + + +def test_get_import_data_from_import_string_valid() -> None: + result = get_import_data_from_import_string("module.submodule:app") + + assert isinstance(result, ImportData) + assert result.app_name == "app" + assert result.import_string == "module.submodule:app" + assert result.module_data.module_import_str == "module.submodule" + assert result.module_data.extra_sys_path == Path(".").resolve() + assert result.module_data.module_paths == [] + + +def test_get_import_data_from_import_string_missing_colon() -> None: + with pytest.raises(FastAPICLIException) as exc_info: + get_import_data_from_import_string("module.submodule") + + assert "Import string must be in the format module.submodule:app_name" in str( + exc_info.value + ) + + +def test_get_import_data_from_import_string_missing_app() -> None: + with pytest.raises(FastAPICLIException) as exc_info: + get_import_data_from_import_string("module.submodule:") + + assert "Import string must be in the format module.submodule:app_name" in str( + exc_info.value + ) + + +def test_get_import_data_from_import_string_missing_module() -> None: + with pytest.raises(FastAPICLIException) as exc_info: + get_import_data_from_import_string(":app") + + assert "Import string must be in the format module.submodule:app_name" in str( + exc_info.value + ) + + +def test_get_import_data_from_import_string_empty() -> None: + with pytest.raises(FastAPICLIException) as exc_info: + get_import_data_from_import_string("") + + assert "Import string must be in the format module.submodule:app_name" in str( + exc_info.value + ) From dcfd6bb1bbcf9d59843fae7a462e1f0d279c35c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Ram=C3=ADrez?= Date: Tue, 9 Sep 2025 14:36:39 +0200 Subject: [PATCH 2/2] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor=20help=20text?= =?UTF-8?q?=20for=20entrypoint=20param?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/fastapi_cli/cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fastapi_cli/cli.py b/src/fastapi_cli/cli.py index 2293b0d0..b7893939 100644 --- a/src/fastapi_cli/cli.py +++ b/src/fastapi_cli/cli.py @@ -232,7 +232,7 @@ def dev( typer.Option( "--entrypoint", "-e", - help="The FastAPI app import string in the format 'module:app_name'.", + help="The FastAPI app import string in the format 'some.importable_module:app_name'.", ), ] = None, proxy_headers: Annotated[ @@ -337,7 +337,7 @@ def run( typer.Option( "--entrypoint", "-e", - help="The FastAPI app import string in the format 'module:app_name'.", + help="The FastAPI app import string in the format 'some.importable_module:app_name'.", ), ] = None, proxy_headers: Annotated[