Skip to content

Commit dd1e4df

Browse files
committed
✨ Add support for reading configuration from pyproject.toml
1 parent 673b4aa commit dd1e4df

File tree

8 files changed

+224
-6
lines changed

8 files changed

+224
-6
lines changed

pyproject.toml

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

4041
[project.optional-dependencies]

src/fastapi_cli/cli.py

Lines changed: 38 additions & 6 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,41 @@ 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(
126+
host=host,
127+
port=port,
128+
entrypoint=entrypoint,
129+
)
130+
except ValidationError as e:
131+
toolkit.print_line()
132+
toolkit.print("[error]Invalid configuration in pyproject.toml:")
133+
toolkit.print_line()
134+
135+
for error in e.errors():
136+
field = ".".join(str(loc) for loc in error["loc"])
137+
toolkit.print(f" [red]•[/red] {field}: {error['msg']}")
138+
139+
toolkit.print_line()
140+
141+
raise typer.Exit(code=1) from None
142+
143+
try:
144+
# Resolve import data with priority: CLI path/app > config entrypoint > auto-discovery
145+
if path or app:
118146
import_data = get_import_data(path=path, app_name=app)
147+
elif config.entrypoint:
148+
import_data = get_import_data_from_import_string(config.entrypoint)
149+
else:
150+
import_data = get_import_data()
119151
except FastAPICLIException as e:
120152
toolkit.print_line()
121153
toolkit.print(f"[error]{e}")
@@ -151,7 +183,7 @@ def _run(
151183
tag="app",
152184
)
153185

154-
url = f"http://{host}:{port}"
186+
url = f"http://{config.host}:{config.port}"
155187
url_docs = f"{url}/docs"
156188

157189
toolkit.print_line()
@@ -179,8 +211,8 @@ def _run(
179211

180212
uvicorn.run(
181213
app=import_string,
182-
host=host,
183-
port=port,
214+
host=config.host,
215+
port=config.port,
184216
reload=reload,
185217
workers=workers,
186218
root_path=root_path,

src/fastapi_cli/config.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
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+
host: str = "127.0.0.1"
13+
port: int = 8000
14+
15+
@classmethod
16+
def _read_pyproject_toml(cls) -> Dict[str, Any]:
17+
"""Read FastAPI configuration from pyproject.toml in current directory."""
18+
pyproject_path = Path.cwd() / "pyproject.toml"
19+
20+
if not pyproject_path.exists():
21+
return {}
22+
23+
try:
24+
import tomllib # type: ignore[import-not-found]
25+
except ImportError:
26+
try:
27+
import tomli as tomllib # type: ignore[no-redef, import-not-found]
28+
except ImportError:
29+
logger.debug("tomli not available, skipping pyproject.toml")
30+
return {}
31+
32+
with open(pyproject_path, "rb") as f:
33+
data = tomllib.load(f)
34+
35+
return data.get("tool", {}).get("fastapi", {}) # type: ignore
36+
37+
@classmethod
38+
def resolve(
39+
cls,
40+
host: Optional[str] = None,
41+
port: Optional[int] = None,
42+
entrypoint: Optional[str] = None,
43+
) -> "FastAPIConfig":
44+
config = cls._read_pyproject_toml()
45+
46+
if host is not None:
47+
config["host"] = host
48+
if port is not None:
49+
config["port"] = port
50+
if entrypoint is not None:
51+
config["entrypoint"] = entrypoint
52+
53+
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: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,23 @@ def test_dev_env_vars_and_args() -> None:
189189
)
190190

191191

192+
def test_entrypoint_mutually_exclusive_with_path() -> None:
193+
result = runner.invoke(app, ["dev", "mymodule.py", "--entrypoint", "other:app"])
194+
195+
assert result.exit_code == 1
196+
assert (
197+
"Cannot use --entrypoint together with path or --app arguments" in result.output
198+
)
199+
200+
201+
def test_entrypoint_mutually_exclusive_with_app() -> None:
202+
result = runner.invoke(app, ["dev", "--app", "myapp", "--entrypoint", "other:app"])
203+
assert result.exit_code == 1
204+
assert (
205+
"Cannot use --entrypoint together with path or --app arguments" in result.output
206+
)
207+
208+
192209
def test_run() -> None:
193210
with changing_dir(assets_path):
194211
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)