diff --git a/src/fastapi_cli/cli.py b/src/fastapi_cli/cli.py index 4348e599..930f82e6 100644 --- a/src/fastapi_cli/cli.py +++ b/src/fastapi_cli/cli.py @@ -8,7 +8,12 @@ from rich.tree import Tree from fastapi_cli.config import FastAPIConfig -from fastapi_cli.discover import get_import_data, get_import_data_from_import_string +from fastapi_cli.discover import ( + AppConfigSource, + ModuleConfigSource, + get_import_data, + get_import_data_from_import_string, +) from fastapi_cli.exceptions import FastAPICLIException from . import __version__ @@ -22,6 +27,15 @@ logger = logging.getLogger(__name__) +SOURCE_DESCRIPTIONS: dict[ModuleConfigSource | AppConfigSource, str] = { + "entrypoint-option": "[blue]--entrypoint[/] CLI option", + "entrypoint-pyproject": "[blue]entrypoint[/] in [blue]pyproject.toml[/]", + "path-argument": "[blue]path[/] CLI argument", + "app-option": "[blue]--app[/] CLI option", + "auto-discovery": "auto-discovery", +} + + try: import uvicorn except ImportError: # pragma: no cover @@ -151,7 +165,9 @@ def _run( if path or app: import_data = get_import_data(path=path, app_name=app) elif config.entrypoint: - import_data = get_import_data_from_import_string(config.entrypoint) + import_data = get_import_data_from_import_string( + config.entrypoint, config.from_pyproject + ) else: import_data = get_import_data() except FastAPICLIException as e: @@ -189,6 +205,16 @@ def _run( tag="app", ) + mod_source_desc = SOURCE_DESCRIPTIONS[import_data.module_config_source] + app_source_desc = SOURCE_DESCRIPTIONS[import_data.app_name_config_source] + toolkit.print_line() + toolkit.print("Configuration sources:", tag="info") + if mod_source_desc == app_source_desc: + toolkit.print(f" • Import string: {mod_source_desc}") + else: + toolkit.print(f" • Module: {mod_source_desc}") + toolkit.print(f" • App name: {app_source_desc}") + url = f"http://{host}:{port}" url_docs = f"{url}/docs" diff --git a/src/fastapi_cli/config.py b/src/fastapi_cli/config.py index e35d16af..1a8b84d8 100644 --- a/src/fastapi_cli/config.py +++ b/src/fastapi_cli/config.py @@ -9,6 +9,7 @@ class FastAPIConfig(BaseModel): entrypoint: StrictStr | None = None + from_pyproject: bool = False @classmethod def _read_pyproject_toml(cls) -> dict[str, Any]: @@ -39,4 +40,6 @@ def resolve(cls, entrypoint: str | None = None) -> "FastAPIConfig": if entrypoint is not None: config["entrypoint"] = entrypoint + config["from_pyproject"] = ("entrypoint" in config) and (entrypoint is None) + return cls.model_validate(config) diff --git a/src/fastapi_cli/discover.py b/src/fastapi_cli/discover.py index 3d49f72d..e70ac64d 100644 --- a/src/fastapi_cli/discover.py +++ b/src/fastapi_cli/discover.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from logging import getLogger from pathlib import Path +from typing import Literal, TypeAlias from fastapi_cli.exceptions import FastAPICLIException @@ -102,18 +103,35 @@ def get_app_name(*, mod_data: ModuleData, app_name: str | None = None) -> str: raise FastAPICLIException("Could not find FastAPI app in module, try using --app") +ModuleConfigSource: TypeAlias = Literal[ + "entrypoint-option", + "entrypoint-pyproject", + "path-argument", + "auto-discovery", +] + +AppConfigSource: TypeAlias = Literal[ + "entrypoint-option", "entrypoint-pyproject", "app-option", "auto-discovery" +] + + @dataclass class ImportData: app_name: str module_data: ModuleData import_string: str + module_config_source: ModuleConfigSource + app_name_config_source: AppConfigSource + def get_import_data( *, path: Path | None = None, app_name: str | None = None ) -> ImportData: + path_config_source: ModuleConfigSource = "path-argument" if not path: path = get_default_path() + path_config_source = "auto-discovery" logger.debug(f"Using path [blue]{path}[/blue]") logger.debug(f"Resolved absolute path {path.resolve()}") @@ -127,11 +145,17 @@ def get_import_data( import_string = f"{mod_data.module_import_str}:{use_app_name}" return ImportData( - app_name=use_app_name, module_data=mod_data, import_string=import_string + app_name=use_app_name, + module_data=mod_data, + import_string=import_string, + module_config_source=path_config_source, + app_name_config_source="app-option" if app_name else "auto-discovery", ) -def get_import_data_from_import_string(import_string: str) -> ImportData: +def get_import_data_from_import_string( + import_string: str, from_pyproject: bool +) -> ImportData: module_str, _, app_name = import_string.partition(":") if not module_str or not app_name: @@ -151,4 +175,10 @@ def get_import_data_from_import_string(import_string: str) -> ImportData: module_paths=[], ), import_string=import_string, + module_config_source=( + "entrypoint-pyproject" if from_pyproject else "entrypoint-option" + ), + app_name_config_source=( + "entrypoint-pyproject" if from_pyproject else "entrypoint-option" + ), ) diff --git a/tests/test_cli.py b/tests/test_cli.py index ddfb808b..cf56e090 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -35,6 +35,9 @@ def test_dev() -> None: "log_config": get_uvicorn_log_config(), } assert "Using import string: single_file_app:app" in result.output + assert "Configuration sources:" in result.output + assert "Module: path CLI argument" in result.output + assert "App name: auto-discovery" in result.output assert "Starting development server 🚀" in result.output assert "Server started at http://127.0.0.1:8000" in result.output assert "Documentation at http://127.0.0.1:8000/docs" in result.output @@ -59,6 +62,8 @@ def test_dev_no_args_auto_discovery() -> None: assert mock_run.call_args.kwargs["port"] == 8000 assert mock_run.call_args.kwargs["reload"] is True assert "Using import string: main:app" in result.output + assert "Configuration sources:" in result.output + assert "Import string: auto-discovery" in result.output def test_dev_package() -> None: @@ -81,6 +86,8 @@ def test_dev_package() -> None: "log_config": get_uvicorn_log_config(), } assert "Using import string: nested_package.package:app" in result.output + assert "Module: path CLI argument" in result.output + assert "App name: auto-discovery" in result.output assert "Starting development server 🚀" in result.output assert "Server started at http://127.0.0.1:8000" in result.output assert "Documentation at http://127.0.0.1:8000/docs" in result.output @@ -131,6 +138,8 @@ def test_dev_args() -> None: "log_config": get_uvicorn_log_config(), } assert "Using import string: single_file_app:api" in result.output + assert "Module: path CLI argument" in result.output + assert "App name: --app CLI option" in result.output assert "Starting development server 🚀" in result.output assert "Server started at http://192.168.0.2:8080" in result.output assert "Documentation at http://192.168.0.2:8080/docs" in result.output @@ -162,6 +171,8 @@ def test_dev_env_vars() -> None: "log_config": get_uvicorn_log_config(), } assert "Using import string: single_file_app:app" in result.output + assert "Module: path CLI argument" in result.output + assert "App name: auto-discovery" in result.output assert "Starting development server 🚀" in result.output assert "Server started at http://127.0.0.1:8111" in result.output assert "Documentation at http://127.0.0.1:8111/docs" in result.output @@ -200,6 +211,8 @@ def test_dev_env_vars_and_args() -> None: "log_config": get_uvicorn_log_config(), } assert "Using import string: single_file_app:app" in result.output + assert "Module: path CLI argument" in result.output + assert "App name: auto-discovery" in result.output assert "Starting development server 🚀" in result.output assert "Server started at http://127.0.0.1:8080" in result.output assert "Documentation at http://127.0.0.1:8080/docs" in result.output @@ -246,6 +259,8 @@ def test_run() -> None: "log_config": get_uvicorn_log_config(), } assert "Using import string: single_file_app:app" in result.output + assert "Module: path CLI argument" in result.output + assert "App name: auto-discovery" in result.output assert "Starting production server 🚀" in result.output assert "Server started at http://0.0.0.0:8000" in result.output assert "Documentation at http://0.0.0.0:8000/docs" in result.output @@ -321,6 +336,8 @@ def test_run_args() -> None: } assert "Using import string: single_file_app:api" in result.output + assert "Module: path CLI argument" in result.output + assert "App name: --app CLI option" in result.output assert "Starting production server 🚀" in result.output assert "Server started at http://192.168.0.2:8080" in result.output assert "Documentation at http://192.168.0.2:8080/docs" in result.output @@ -352,6 +369,8 @@ def test_run_env_vars() -> None: "log_config": get_uvicorn_log_config(), } assert "Using import string: single_file_app:app" in result.output + assert "Module: path CLI argument" in result.output + assert "App name: auto-discovery" in result.output assert "Starting production server 🚀" in result.output assert "Server started at http://0.0.0.0:8111" in result.output assert "Documentation at http://0.0.0.0:8111/docs" in result.output @@ -386,6 +405,8 @@ def test_run_env_vars_and_args() -> None: "log_config": get_uvicorn_log_config(), } assert "Using import string: single_file_app:app" in result.output + assert "Module: path CLI argument" in result.output + assert "App name: auto-discovery" in result.output assert "Starting production server 🚀" in result.output assert "Server started at http://0.0.0.0:8080" in result.output assert "Documentation at http://0.0.0.0:8080/docs" in result.output @@ -498,6 +519,7 @@ def test_dev_with_import_string() -> None: "log_config": get_uvicorn_log_config(), } assert "Using import string: single_file_app:api" in result.output + assert "Import string: --entrypoint CLI option" in result.output def test_run_with_import_string() -> None: @@ -520,6 +542,7 @@ def test_run_with_import_string() -> None: "log_config": get_uvicorn_log_config(), } assert "Using import string: single_file_app:app" in result.output + assert "Import string: --entrypoint CLI option" in result.output def test_script() -> None: diff --git a/tests/test_cli_pyproject.py b/tests/test_cli_pyproject.py index f2b1678f..b2acff5b 100644 --- a/tests/test_cli_pyproject.py +++ b/tests/test_cli_pyproject.py @@ -26,6 +26,8 @@ def test_dev_with_pyproject_app_config_uses() -> None: assert mock_run.call_args.kwargs["reload"] is True assert "Using import string: my_module:app" in result.output + assert "Configuration sources:" in result.output + assert "Import string: entrypoint in pyproject.toml" in result.output def test_run_with_pyproject_app_config() -> None: @@ -42,6 +44,8 @@ def test_run_with_pyproject_app_config() -> None: assert mock_run.call_args.kwargs["reload"] is False assert "Using import string: my_module:app" in result.output + assert "Configuration sources:" in result.output + assert "Import string: entrypoint in pyproject.toml" in result.output def test_cli_arg_overrides_pyproject_config() -> None: @@ -54,6 +58,9 @@ def test_cli_arg_overrides_pyproject_config() -> None: assert result.exit_code == 0, result.output assert mock_run.call_args.kwargs["app"] == "another_module:app" + assert "Configuration sources:" in result.output + assert "Module: path CLI argument" in result.output + assert "App name: auto-discovery" in result.output def test_pyproject_app_config_invalid_format() -> None: diff --git a/tests/test_discover.py b/tests/test_discover.py index b1052050..22f3c9cc 100644 --- a/tests/test_discover.py +++ b/tests/test_discover.py @@ -12,7 +12,7 @@ def test_get_import_data_from_import_string_valid() -> None: - result = get_import_data_from_import_string("module.submodule:app") + result = get_import_data_from_import_string("module.submodule:app", False) assert isinstance(result, ImportData) assert result.app_name == "app" @@ -20,11 +20,26 @@ def test_get_import_data_from_import_string_valid() -> None: assert result.module_data.module_import_str == "module.submodule" assert result.module_data.extra_sys_path == Path(".").resolve() assert result.module_data.module_paths == [] + assert result.module_config_source == "entrypoint-option" + assert result.app_name_config_source == "entrypoint-option" + + +def test_get_import_data_from_import_string_pyproject_valid() -> None: + result = get_import_data_from_import_string("module.submodule:app", True) + + 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 == [] + assert result.module_config_source == "entrypoint-pyproject" + assert result.app_name_config_source == "entrypoint-pyproject" 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") + get_import_data_from_import_string("module.submodule", False) assert "Import string must be in the format module.submodule:app_name" in str( exc_info.value @@ -33,7 +48,7 @@ def test_get_import_data_from_import_string_missing_colon() -> None: 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:") + get_import_data_from_import_string("module.submodule:", False) assert "Import string must be in the format module.submodule:app_name" in str( exc_info.value @@ -42,7 +57,7 @@ def test_get_import_data_from_import_string_missing_app() -> None: 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") + get_import_data_from_import_string(":app", False) assert "Import string must be in the format module.submodule:app_name" in str( exc_info.value @@ -51,8 +66,7 @@ def test_get_import_data_from_import_string_missing_module() -> None: def test_get_import_data_from_import_string_empty() -> None: with pytest.raises(FastAPICLIException) as exc_info: - get_import_data_from_import_string("") - + get_import_data_from_import_string("", False) assert "Import string must be in the format module.submodule:app_name" in str( exc_info.value )