Skip to content

Commit 91c11f7

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

File tree

6 files changed

+251
-6
lines changed

6 files changed

+251
-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+
"pydantic-settings",
3839
]
3940

4041
[project.optional-dependencies]

src/fastapi_cli/cli.py

Lines changed: 42 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,45 @@ def _run(
111113
"Searching for package file structure from directories with [blue]__init__.py[/blue] files"
112114
)
113115

116+
# Validate mutually exclusive options
117+
if entrypoint and (path or app):
118+
toolkit.print_line()
119+
toolkit.print(
120+
"[error]Cannot use --entrypoint together with path or --app arguments"
121+
)
122+
toolkit.print_line()
123+
raise typer.Exit(code=1)
124+
114125
try:
115-
if entrypoint:
116-
import_data = get_import_data_from_import_string(entrypoint)
117-
else:
126+
# Let pydantic merge CLI params with pyproject.toml (CLI takes priority)
127+
# Filter out None values so pyproject.toml values are used as defaults
128+
cli_params = {
129+
k: v
130+
for k, v in {"host": host, "port": port, "entrypoint": entrypoint}.items()
131+
if v is not None
132+
}
133+
config = FastAPIConfig.model_validate(cli_params)
134+
except ValidationError as e:
135+
toolkit.print_line()
136+
toolkit.print("[error]Invalid configuration in pyproject.toml:")
137+
toolkit.print_line()
138+
139+
for error in e.errors():
140+
field = ".".join(str(loc) for loc in error["loc"])
141+
toolkit.print(f" [red]•[/red] {field}: {error['msg']}")
142+
143+
toolkit.print_line()
144+
145+
raise typer.Exit(code=1) from None
146+
147+
try:
148+
# Resolve import data with priority: CLI path/app > config entrypoint > auto-discovery
149+
if path or app:
118150
import_data = get_import_data(path=path, app_name=app)
151+
elif config.entrypoint:
152+
import_data = get_import_data_from_import_string(config.entrypoint)
153+
else:
154+
import_data = get_import_data()
119155
except FastAPICLIException as e:
120156
toolkit.print_line()
121157
toolkit.print(f"[error]{e}")
@@ -151,7 +187,7 @@ def _run(
151187
tag="app",
152188
)
153189

154-
url = f"http://{host}:{port}"
190+
url = f"http://{config.host}:{config.port}"
155191
url_docs = f"{url}/docs"
156192

157193
toolkit.print_line()
@@ -179,8 +215,8 @@ def _run(
179215

180216
uvicorn.run(
181217
app=import_string,
182-
host=host,
183-
port=port,
218+
host=config.host,
219+
port=config.port,
184220
reload=reload,
185221
workers=workers,
186222
root_path=root_path,

src/fastapi_cli/config.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import logging
2+
from typing import Optional
3+
4+
from pydantic_settings import (
5+
BaseSettings,
6+
PydanticBaseSettingsSource,
7+
PyprojectTomlConfigSettingsSource,
8+
SettingsConfigDict,
9+
)
10+
11+
logger = logging.getLogger(__name__)
12+
13+
14+
class FastAPIConfig(BaseSettings):
15+
entrypoint: Optional[str] = None
16+
host: Optional[str] = None
17+
port: Optional[int] = None
18+
19+
model_config = SettingsConfigDict(
20+
pyproject_toml_table_header=("tool", "fastapi"),
21+
)
22+
23+
@classmethod
24+
def settings_customise_sources(
25+
cls,
26+
settings_cls: type[BaseSettings],
27+
init_settings: PydanticBaseSettingsSource,
28+
env_settings: PydanticBaseSettingsSource,
29+
dotenv_settings: PydanticBaseSettingsSource,
30+
file_secret_settings: PydanticBaseSettingsSource,
31+
) -> tuple[PydanticBaseSettingsSource, ...]:
32+
return (
33+
init_settings,
34+
PyprojectTomlConfigSettingsSource(settings_cls),
35+
)
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 = "mymodule:app"

tests/test_cli_pyproject.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
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 fastapi_cli.utils.cli import get_uvicorn_log_config
9+
from tests.utils import changing_dir
10+
11+
runner = CliRunner()
12+
13+
assets_path = Path(__file__).parent / "assets"
14+
15+
16+
def test_dev_with_pyproject_app_config() -> None:
17+
with changing_dir(assets_path / "pyproject_config"), patch.object(
18+
uvicorn, "run"
19+
) as mock_run:
20+
result = runner.invoke(app, ["dev"])
21+
assert result.exit_code == 0, result.output
22+
assert mock_run.called
23+
assert mock_run.call_args
24+
assert mock_run.call_args.kwargs == {
25+
"app": "mymodule:app",
26+
"host": "127.0.0.1",
27+
"port": 8000,
28+
"reload": True,
29+
"workers": None,
30+
"root_path": "",
31+
"proxy_headers": True,
32+
"forwarded_allow_ips": None,
33+
"log_config": get_uvicorn_log_config(),
34+
}
35+
assert "Using import string: mymodule:app" in result.output
36+
37+
38+
def test_run_with_pyproject_app_config() -> None:
39+
with changing_dir(assets_path / "pyproject_config"), patch.object(
40+
uvicorn, "run"
41+
) as mock_run:
42+
result = runner.invoke(app, ["run"])
43+
assert result.exit_code == 0, result.output
44+
assert mock_run.called
45+
assert mock_run.call_args
46+
assert mock_run.call_args.kwargs["app"] == "mymodule:app"
47+
assert mock_run.call_args.kwargs["host"] == "0.0.0.0"
48+
assert mock_run.call_args.kwargs["port"] == 8000
49+
assert mock_run.call_args.kwargs["reload"] is False
50+
51+
52+
def test_cli_arg_overrides_pyproject_config() -> None:
53+
"""Test that CLI arguments override pyproject.toml configuration"""
54+
with changing_dir(assets_path / "pyproject_config"):
55+
# Create another file to test override
56+
other_app = assets_path / "pyproject_config" / "other.py"
57+
other_app.write_text("""
58+
from fastapi import FastAPI
59+
60+
api = FastAPI()
61+
62+
@api.get("/")
63+
def read_root():
64+
return {"source": "other"}
65+
""")
66+
67+
try:
68+
with patch.object(uvicorn, "run") as mock_run:
69+
result = runner.invoke(app, ["dev", "other.py", "--app", "api"])
70+
assert result.exit_code == 0, result.output
71+
assert mock_run.called
72+
assert mock_run.call_args
73+
assert mock_run.call_args.kwargs["app"] == "other:api"
74+
finally:
75+
other_app.unlink()
76+
77+
78+
def test_pyproject_app_config_invalid_format() -> None:
79+
"""Test error handling for invalid app format in pyproject.toml"""
80+
81+
# Create a test directory with invalid config
82+
test_dir = assets_path / "pyproject_invalid_config"
83+
test_dir.mkdir(exist_ok=True)
84+
85+
pyproject_file = test_dir / "pyproject.toml"
86+
pyproject_file.write_text("""
87+
[tool.fastapi]
88+
entrypoint = "invalid_format_without_colon"
89+
""")
90+
91+
try:
92+
with changing_dir(test_dir):
93+
result = runner.invoke(app, ["dev"])
94+
assert result.exit_code == 1
95+
assert (
96+
"Import string must be in the format module.submodule:app_name"
97+
in result.output
98+
)
99+
finally:
100+
pyproject_file.unlink()
101+
test_dir.rmdir()
102+
103+
104+
def test_dev_without_pyproject_toml() -> None:
105+
"""Test that dev command works without pyproject.toml file"""
106+
# Use an existing test directory that has a main.py but no pyproject.toml
107+
test_dir = assets_path / "default_files" / "default_main"
108+
109+
with changing_dir(test_dir):
110+
with patch.object(uvicorn, "run") as mock_run:
111+
result = runner.invoke(app, ["dev"])
112+
assert result.exit_code == 0, result.output
113+
assert mock_run.called
114+
assert mock_run.call_args
115+
# Should use defaults since no config file
116+
assert mock_run.call_args.kwargs["host"] == "127.0.0.1"
117+
assert mock_run.call_args.kwargs["port"] == 8000
118+
assert mock_run.call_args.kwargs["app"] == "main:app"
119+
120+
121+
def test_pyproject_validation_error() -> None:
122+
"""Test error handling for validation errors in pyproject.toml"""
123+
# Create a test directory with invalid config (invalid entrypoint type)
124+
test_dir = assets_path / "pyproject_validation_error"
125+
test_dir.mkdir(exist_ok=True)
126+
127+
pyproject_file = test_dir / "pyproject.toml"
128+
pyproject_file.write_text("""
129+
[tool.fastapi]
130+
entrypoint = 123
131+
""")
132+
133+
try:
134+
with changing_dir(test_dir):
135+
result = runner.invoke(app, ["dev"])
136+
assert result.exit_code == 1
137+
assert "Invalid configuration in pyproject.toml:" in result.output
138+
assert "entrypoint" in result.output.lower()
139+
finally:
140+
pyproject_file.unlink()
141+
test_dir.rmdir()
142+
143+
144+
def test_entrypoint_mutually_exclusive_with_path() -> None:
145+
"""Test that --entrypoint cannot be used with path argument"""
146+
with changing_dir(assets_path / "pyproject_config"):
147+
result = runner.invoke(app, ["dev", "mymodule.py", "--entrypoint", "other:app"])
148+
assert result.exit_code == 1
149+
assert (
150+
"Cannot use --entrypoint together with path or --app arguments"
151+
in result.output
152+
)
153+
154+
155+
def test_entrypoint_mutually_exclusive_with_app() -> None:
156+
"""Test that --entrypoint cannot be used with --app flag"""
157+
with changing_dir(assets_path / "pyproject_config"):
158+
result = runner.invoke(app, ["dev", "--app", "myapp", "--entrypoint", "other:app"])
159+
assert result.exit_code == 1
160+
assert (
161+
"Cannot use --entrypoint together with path or --app arguments"
162+
in result.output
163+
)

0 commit comments

Comments
 (0)