Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ dependencies = [
"typer >= 0.15.1",
"uvicorn[standard] >= 0.15.0",
"rich-toolkit >= 0.14.8",
"tomli >= 2.0.0; python_version < '3.11'"
]

[project.optional-dependencies]
Expand Down
44 changes: 38 additions & 6 deletions src/fastapi_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
from typing import Any, List, Union

import typer
from pydantic import ValidationError
from rich import print
from rich.tree import Tree
from typing_extensions import Annotated

from fastapi_cli.config import FastAPIConfig
from fastapi_cli.discover import get_import_data, get_import_data_from_import_string
from fastapi_cli.exceptions import FastAPICLIException

Expand Down Expand Up @@ -111,11 +113,41 @@ def _run(
"Searching for package file structure from directories with [blue]__init__.py[/blue] files"
)

if entrypoint and (path or app):
toolkit.print_line()
toolkit.print(
"[error]Cannot use --entrypoint together with path or --app arguments"
)
toolkit.print_line()
raise typer.Exit(code=1)

try:
if entrypoint:
import_data = get_import_data_from_import_string(entrypoint)
else:
config = FastAPIConfig.resolve(
host=host,
port=port,
entrypoint=entrypoint,
)
except ValidationError as e:
toolkit.print_line()
toolkit.print("[error]Invalid configuration in pyproject.toml:")
toolkit.print_line()

for error in e.errors():
field = ".".join(str(loc) for loc in error["loc"])
toolkit.print(f" [red]•[/red] {field}: {error['msg']}")

toolkit.print_line()

raise typer.Exit(code=1) from None

try:
# Resolve import data with priority: CLI path/app > config entrypoint > auto-discovery
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)
else:
import_data = get_import_data()
except FastAPICLIException as e:
toolkit.print_line()
toolkit.print(f"[error]{e}")
Expand Down Expand Up @@ -151,7 +183,7 @@ def _run(
tag="app",
)

url = f"http://{host}:{port}"
url = f"http://{config.host}:{config.port}"
url_docs = f"{url}/docs"

toolkit.print_line()
Expand Down Expand Up @@ -179,8 +211,8 @@ def _run(

uvicorn.run(
app=import_string,
host=host,
port=port,
host=config.host,
port=config.port,
reload=reload,
workers=workers,
root_path=root_path,
Expand Down
53 changes: 53 additions & 0 deletions src/fastapi_cli/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import logging
from pathlib import Path
from typing import Any, Dict, Optional

from pydantic import BaseModel

logger = logging.getLogger(__name__)


class FastAPIConfig(BaseModel):
entrypoint: Optional[str] = None
host: str = "127.0.0.1"
port: int = 8000

@classmethod
def _read_pyproject_toml(cls) -> Dict[str, Any]:
"""Read FastAPI configuration from pyproject.toml in current directory."""
pyproject_path = Path.cwd() / "pyproject.toml"

if not pyproject_path.exists():
return {}

try:
import tomllib # type: ignore[import-not-found, unused-ignore]
except ImportError:
try:
import tomli as tomllib # type: ignore[no-redef, import-not-found, unused-ignore]
except ImportError: # pragma: no cover
logger.debug("tomli not available, skipping pyproject.toml")
return {}

with open(pyproject_path, "rb") as f:
data = tomllib.load(f)

return data.get("tool", {}).get("fastapi", {}) # type: ignore

@classmethod
def resolve(
cls,
host: Optional[str] = None,
port: Optional[int] = None,
entrypoint: Optional[str] = None,
) -> "FastAPIConfig":
config = cls._read_pyproject_toml()

if host is not None:
config["host"] = host
if port is not None:
config["port"] = port
if entrypoint is not None:
config["entrypoint"] = entrypoint

return FastAPIConfig.model_validate(config)
8 changes: 8 additions & 0 deletions tests/assets/pyproject_config/another_module.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from fastapi import FastAPI

app = FastAPI()


@app.get("/")
def read_root():
return {"Hello": "World"}
8 changes: 8 additions & 0 deletions tests/assets/pyproject_config/my_module.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from fastapi import FastAPI

app = FastAPI()


@app.get("/")
def read_root():
return {"Hello": "World"}
2 changes: 2 additions & 0 deletions tests/assets/pyproject_config/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[tool.fastapi]
entrypoint = "my_module:app"
32 changes: 32 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,21 @@ def test_dev() -> None:
assert "🐍 single_file_app.py" in result.output


def test_dev_no_args_auto_discovery() -> None:
"""Test that auto-discovery works when no args and no pyproject.toml entrypoint"""
with changing_dir(assets_path / "default_files" / "default_main"):
with patch.object(uvicorn, "run") as mock_run:
result = runner.invoke(app, ["dev"]) # No path argument
assert result.exit_code == 0, result.output
assert mock_run.called
assert mock_run.call_args
assert mock_run.call_args.kwargs["app"] == "main:app"
assert mock_run.call_args.kwargs["host"] == "127.0.0.1"
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


def test_dev_package() -> None:
with changing_dir(assets_path):
with patch.object(uvicorn, "run") as mock_run:
Expand Down Expand Up @@ -189,6 +204,23 @@ def test_dev_env_vars_and_args() -> None:
)


def test_entrypoint_mutually_exclusive_with_path() -> None:
result = runner.invoke(app, ["dev", "mymodule.py", "--entrypoint", "other:app"])

assert result.exit_code == 1
assert (
"Cannot use --entrypoint together with path or --app arguments" in result.output
)


def test_entrypoint_mutually_exclusive_with_app() -> None:
result = runner.invoke(app, ["dev", "--app", "myapp", "--entrypoint", "other:app"])
assert result.exit_code == 1
assert (
"Cannot use --entrypoint together with path or --app arguments" in result.output
)


def test_run() -> None:
with changing_dir(assets_path):
with patch.object(uvicorn, "run") as mock_run:
Expand Down
97 changes: 97 additions & 0 deletions tests/test_cli_pyproject.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
from pathlib import Path
from unittest.mock import patch

import uvicorn
from typer.testing import CliRunner

from fastapi_cli.cli import app
from tests.utils import changing_dir

runner = CliRunner()

assets_path = Path(__file__).parent / "assets"


def test_dev_with_pyproject_app_config_uses() -> None:
with changing_dir(assets_path / "pyproject_config"), patch.object(
uvicorn, "run"
) as mock_run:
result = runner.invoke(app, ["dev"])
assert result.exit_code == 0, result.output

assert mock_run.call_args.kwargs["app"] == "my_module:app"
assert mock_run.call_args.kwargs["host"] == "127.0.0.1"
assert mock_run.call_args.kwargs["port"] == 8000
assert mock_run.call_args.kwargs["reload"] is True

assert "Using import string: my_module:app" in result.output


def test_run_with_pyproject_app_config() -> None:
with changing_dir(assets_path / "pyproject_config"), patch.object(
uvicorn, "run"
) as mock_run:
result = runner.invoke(app, ["run"])
assert result.exit_code == 0, result.output

assert mock_run.call_args.kwargs["app"] == "my_module:app"
assert mock_run.call_args.kwargs["host"] == "0.0.0.0"
assert mock_run.call_args.kwargs["port"] == 8000
assert mock_run.call_args.kwargs["reload"] is False

assert "Using import string: my_module:app" in result.output


def test_cli_arg_overrides_pyproject_config() -> None:
with changing_dir(assets_path / "pyproject_config"), patch.object(
uvicorn, "run"
) as mock_run:
result = runner.invoke(app, ["dev", "another_module.py"])

assert result.exit_code == 0, result.output

assert mock_run.call_args.kwargs["app"] == "another_module:app"


def test_pyproject_app_config_invalid_format() -> None:
test_dir = assets_path / "pyproject_invalid_config"
test_dir.mkdir(exist_ok=True)

pyproject_file = test_dir / "pyproject.toml"
pyproject_file.write_text("""
[tool.fastapi]
entrypoint = "invalid_format_without_colon"
""")

try:
with changing_dir(test_dir):
result = runner.invoke(app, ["dev"])
assert result.exit_code == 1
assert (
"Import string must be in the format module.submodule:app_name"
in result.output
)
finally:
pyproject_file.unlink()
test_dir.rmdir()


def test_pyproject_validation_error() -> None:
test_dir = assets_path / "pyproject_validation_error"
test_dir.mkdir(exist_ok=True)

pyproject_file = test_dir / "pyproject.toml"
pyproject_file.write_text("""
[tool.fastapi]
entrypoint = 123
""")

try:
with changing_dir(test_dir):
result = runner.invoke(app, ["dev"])
assert result.exit_code == 1
assert "Invalid configuration in pyproject.toml:" in result.output
assert "entrypoint" in result.output.lower()
finally:
pyproject_file.unlink()
test_dir.rmdir()