Skip to content
Draft
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
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:
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"
17 changes: 17 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,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()
Loading