Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions src/fastapi_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"

Expand Down
3 changes: 3 additions & 0 deletions src/fastapi_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

class FastAPIConfig(BaseModel):
entrypoint: StrictStr | None = None
from_pyproject: bool = False

@classmethod
def _read_pyproject_toml(cls) -> dict[str, Any]:
Expand Down Expand Up @@ -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)
34 changes: 32 additions & 2 deletions src/fastapi_cli/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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()}")
Expand All @@ -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:
Expand All @@ -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"
),
)
23 changes: 23 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions tests/test_cli_pyproject.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down
26 changes: 20 additions & 6 deletions tests/test_discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,34 @@


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"
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-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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
)
Loading