Skip to content

Commit 03aee40

Browse files
authored
✨ Add support for reading configuration from pyproject.toml (#236)
1 parent 05f034a commit 03aee40

File tree

8 files changed

+221
-3
lines changed

8 files changed

+221
-3
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ dependencies = [
3636
"typer >= 0.15.1",
3737
"uvicorn[standard] >= 0.15.0",
3838
"rich-toolkit >= 0.14.8",
39+
"tomli >= 2.0.0; python_version < '3.11'"
3940
]
4041

4142
[project.optional-dependencies]

src/fastapi_cli/cli.py

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
from typing import Any, List, Union
44

55
import typer
6+
from pydantic import ValidationError
67
from rich import print
78
from rich.tree import Tree
89
from typing_extensions import Annotated
910

11+
from fastapi_cli.config import FastAPIConfig
1012
from fastapi_cli.discover import get_import_data, get_import_data_from_import_string
1113
from fastapi_cli.exceptions import FastAPICLIException
1214

@@ -111,11 +113,37 @@ def _run(
111113
"Searching for package file structure from directories with [blue]__init__.py[/blue] files"
112114
)
113115

116+
if entrypoint and (path or app):
117+
toolkit.print_line()
118+
toolkit.print(
119+
"[error]Cannot use --entrypoint together with path or --app arguments"
120+
)
121+
toolkit.print_line()
122+
raise typer.Exit(code=1)
123+
114124
try:
115-
if entrypoint:
116-
import_data = get_import_data_from_import_string(entrypoint)
117-
else:
125+
config = FastAPIConfig.resolve(entrypoint=entrypoint)
126+
except ValidationError as e:
127+
toolkit.print_line()
128+
toolkit.print("[error]Invalid configuration in pyproject.toml:")
129+
toolkit.print_line()
130+
131+
for error in e.errors():
132+
field = ".".join(str(loc) for loc in error["loc"])
133+
toolkit.print(f" [red]•[/red] {field}: {error['msg']}")
134+
135+
toolkit.print_line()
136+
137+
raise typer.Exit(code=1) from None
138+
139+
try:
140+
# Resolve import data with priority: CLI path/app > config entrypoint > auto-discovery
141+
if path or app:
118142
import_data = get_import_data(path=path, app_name=app)
143+
elif config.entrypoint:
144+
import_data = get_import_data_from_import_string(config.entrypoint)
145+
else:
146+
import_data = get_import_data()
119147
except FastAPICLIException as e:
120148
toolkit.print_line()
121149
toolkit.print(f"[error]{e}")

src/fastapi_cli/config.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import logging
2+
from pathlib import Path
3+
from typing import Any, Dict, Optional
4+
5+
from pydantic import BaseModel
6+
7+
logger = logging.getLogger(__name__)
8+
9+
10+
class FastAPIConfig(BaseModel):
11+
entrypoint: Optional[str] = None
12+
13+
@classmethod
14+
def _read_pyproject_toml(cls) -> Dict[str, Any]:
15+
"""Read FastAPI configuration from pyproject.toml in current directory."""
16+
pyproject_path = Path.cwd() / "pyproject.toml"
17+
18+
if not pyproject_path.exists():
19+
return {}
20+
21+
try:
22+
import tomllib # type: ignore[import-not-found, unused-ignore]
23+
except ImportError:
24+
try:
25+
import tomli as tomllib # type: ignore[no-redef, import-not-found, unused-ignore]
26+
except ImportError: # pragma: no cover
27+
logger.debug("tomli not available, skipping pyproject.toml")
28+
return {}
29+
30+
with open(pyproject_path, "rb") as f:
31+
data = tomllib.load(f)
32+
33+
return data.get("tool", {}).get("fastapi", {}) # type: ignore
34+
35+
@classmethod
36+
def resolve(cls, entrypoint: Optional[str] = None) -> "FastAPIConfig":
37+
config = cls._read_pyproject_toml()
38+
39+
if entrypoint is not None:
40+
config["entrypoint"] = entrypoint
41+
42+
return FastAPIConfig.model_validate(config)
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from fastapi import FastAPI
2+
3+
app = FastAPI()
4+
5+
6+
@app.get("/")
7+
def read_root():
8+
return {"Hello": "World"}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from fastapi import FastAPI
2+
3+
app = FastAPI()
4+
5+
6+
@app.get("/")
7+
def read_root():
8+
return {"Hello": "World"}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[tool.fastapi]
2+
entrypoint = "my_module:app"

tests/test_cli.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,21 @@ def test_dev() -> None:
4545
assert "🐍 single_file_app.py" in result.output
4646

4747

48+
def test_dev_no_args_auto_discovery() -> None:
49+
"""Test that auto-discovery works when no args and no pyproject.toml entrypoint"""
50+
with changing_dir(assets_path / "default_files" / "default_main"):
51+
with patch.object(uvicorn, "run") as mock_run:
52+
result = runner.invoke(app, ["dev"]) # No path argument
53+
assert result.exit_code == 0, result.output
54+
assert mock_run.called
55+
assert mock_run.call_args
56+
assert mock_run.call_args.kwargs["app"] == "main:app"
57+
assert mock_run.call_args.kwargs["host"] == "127.0.0.1"
58+
assert mock_run.call_args.kwargs["port"] == 8000
59+
assert mock_run.call_args.kwargs["reload"] is True
60+
assert "Using import string: main:app" in result.output
61+
62+
4863
def test_dev_package() -> None:
4964
with changing_dir(assets_path):
5065
with patch.object(uvicorn, "run") as mock_run:
@@ -189,6 +204,23 @@ def test_dev_env_vars_and_args() -> None:
189204
)
190205

191206

207+
def test_entrypoint_mutually_exclusive_with_path() -> None:
208+
result = runner.invoke(app, ["dev", "mymodule.py", "--entrypoint", "other:app"])
209+
210+
assert result.exit_code == 1
211+
assert (
212+
"Cannot use --entrypoint together with path or --app arguments" in result.output
213+
)
214+
215+
216+
def test_entrypoint_mutually_exclusive_with_app() -> None:
217+
result = runner.invoke(app, ["dev", "--app", "myapp", "--entrypoint", "other:app"])
218+
assert result.exit_code == 1
219+
assert (
220+
"Cannot use --entrypoint together with path or --app arguments" in result.output
221+
)
222+
223+
192224
def test_run() -> None:
193225
with changing_dir(assets_path):
194226
with patch.object(uvicorn, "run") as mock_run:

tests/test_cli_pyproject.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
from pathlib import Path
2+
from unittest.mock import patch
3+
4+
import uvicorn
5+
from typer.testing import CliRunner
6+
7+
from fastapi_cli.cli import app
8+
from tests.utils import changing_dir
9+
10+
runner = CliRunner()
11+
12+
assets_path = Path(__file__).parent / "assets"
13+
14+
15+
def test_dev_with_pyproject_app_config_uses() -> None:
16+
with changing_dir(assets_path / "pyproject_config"), patch.object(
17+
uvicorn, "run"
18+
) as mock_run:
19+
result = runner.invoke(app, ["dev"])
20+
assert result.exit_code == 0, result.output
21+
22+
assert mock_run.call_args.kwargs["app"] == "my_module:app"
23+
assert mock_run.call_args.kwargs["host"] == "127.0.0.1"
24+
assert mock_run.call_args.kwargs["port"] == 8000
25+
assert mock_run.call_args.kwargs["reload"] is True
26+
27+
assert "Using import string: my_module:app" in result.output
28+
29+
30+
def test_run_with_pyproject_app_config() -> None:
31+
with changing_dir(assets_path / "pyproject_config"), patch.object(
32+
uvicorn, "run"
33+
) as mock_run:
34+
result = runner.invoke(app, ["run"])
35+
assert result.exit_code == 0, result.output
36+
37+
assert mock_run.call_args.kwargs["app"] == "my_module:app"
38+
assert mock_run.call_args.kwargs["host"] == "0.0.0.0"
39+
assert mock_run.call_args.kwargs["port"] == 8000
40+
assert mock_run.call_args.kwargs["reload"] is False
41+
42+
assert "Using import string: my_module:app" in result.output
43+
44+
45+
def test_cli_arg_overrides_pyproject_config() -> None:
46+
with changing_dir(assets_path / "pyproject_config"), patch.object(
47+
uvicorn, "run"
48+
) as mock_run:
49+
result = runner.invoke(app, ["dev", "another_module.py"])
50+
51+
assert result.exit_code == 0, result.output
52+
53+
assert mock_run.call_args.kwargs["app"] == "another_module:app"
54+
55+
56+
def test_pyproject_app_config_invalid_format() -> None:
57+
test_dir = assets_path / "pyproject_invalid_config"
58+
test_dir.mkdir(exist_ok=True)
59+
60+
pyproject_file = test_dir / "pyproject.toml"
61+
pyproject_file.write_text("""
62+
[tool.fastapi]
63+
entrypoint = "invalid_format_without_colon"
64+
""")
65+
66+
try:
67+
with changing_dir(test_dir):
68+
result = runner.invoke(app, ["dev"])
69+
assert result.exit_code == 1
70+
assert (
71+
"Import string must be in the format module.submodule:app_name"
72+
in result.output
73+
)
74+
finally:
75+
pyproject_file.unlink()
76+
test_dir.rmdir()
77+
78+
79+
def test_pyproject_validation_error() -> None:
80+
test_dir = assets_path / "pyproject_validation_error"
81+
test_dir.mkdir(exist_ok=True)
82+
83+
pyproject_file = test_dir / "pyproject.toml"
84+
pyproject_file.write_text("""
85+
[tool.fastapi]
86+
entrypoint = 123
87+
""")
88+
89+
try:
90+
with changing_dir(test_dir):
91+
result = runner.invoke(app, ["dev"])
92+
assert result.exit_code == 1
93+
assert "Invalid configuration in pyproject.toml:" in result.output
94+
assert "entrypoint" in result.output.lower()
95+
finally:
96+
pyproject_file.unlink()
97+
test_dir.rmdir()

0 commit comments

Comments
 (0)