Skip to content

Commit 2f2048f

Browse files
committed
feat: Enable configuration via pyproject.toml or env vars
1 parent 35c0428 commit 2f2048f

File tree

3 files changed

+108
-5
lines changed

3 files changed

+108
-5
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ classifiers = [
3434
]
3535
dependencies = [
3636
"typer >= 0.12.3",
37+
"tomli >= 2.0.1; python_version < '3.11'",
3738
]
3839

3940
[project.optional-dependencies]

src/fastapi_cli/cli.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from fastapi_cli.exceptions import FastAPICLIException
1414

1515
from . import __version__
16+
from .config import CommandWithProjectConfig
1617
from .logging import setup_logging
1718

1819
app = typer.Typer(rich_markup_mode="rich")
@@ -92,12 +93,13 @@ def _run(
9293
)
9394

9495

95-
@app.command()
96+
@app.command(cls=CommandWithProjectConfig)
9697
def dev(
9798
path: Annotated[
9899
Union[Path, None],
99100
typer.Argument(
100-
help="A path to a Python file or package directory (with [blue]__init__.py[/blue] files) containing a [bold]FastAPI[/bold] app. If not provided, a default set of paths will be tried."
101+
help="A path to a Python file or package directory (with [blue]__init__.py[/blue] files) containing a [bold]FastAPI[/bold] app. If not provided, a default set of paths will be tried.",
102+
envvar=["FASTAPI_DEV_PATH", "FASTAPI_PATH"],
101103
),
102104
] = None,
103105
*,
@@ -175,12 +177,13 @@ def dev(
175177
)
176178

177179

178-
@app.command()
180+
@app.command(cls=CommandWithProjectConfig)
179181
def run(
180182
path: Annotated[
181183
Union[Path, None],
182184
typer.Argument(
183-
help="A path to a Python file or package directory (with [blue]__init__.py[/blue] files) containing a [bold]FastAPI[/bold] app. If not provided, a default set of paths will be tried."
185+
help="A path to a Python file or package directory (with [blue]__init__.py[/blue] files) containing a [bold]FastAPI[/bold] app. If not provided, a default set of paths will be tried.",
186+
envvar=["FASTAPI_RUN_PATH", "FASTAPI_PATH"],
184187
),
185188
] = None,
186189
*,
@@ -266,4 +269,4 @@ def run(
266269

267270

268271
def main() -> None:
269-
app()
272+
app(auto_envvar_prefix="FASTAPI")

src/fastapi_cli/config.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
import sys
5+
from pathlib import Path
6+
from typing import Any, Sequence
7+
8+
from click import BadParameter, Context
9+
from typer.core import TyperCommand
10+
11+
if sys.version_info >= (3, 11):
12+
import tomllib
13+
else:
14+
import tomli as tomllib
15+
16+
logger = logging.getLogger(__name__)
17+
18+
19+
def get_toml_key(config: dict[str, Any], keys: Sequence[str]) -> dict[str, Any]:
20+
for key in keys:
21+
config = config.get(key, {})
22+
return config
23+
24+
25+
def read_pyproject_file(keys: Sequence[str]) -> dict[str, Any] | None:
26+
path = Path("pyproject.toml")
27+
if not path.exists():
28+
return None
29+
30+
with path.open("rb") as f:
31+
data = tomllib.load(f)
32+
33+
config = get_toml_key(data, keys)
34+
return config or None
35+
36+
37+
class CommandWithProjectConfig(TyperCommand):
38+
"""Command class which loads parameters from a pyproject.toml file.
39+
40+
It will search the current directory and all parent directories for
41+
a `pyproject.toml` file, then change directories to that file so all paths
42+
defined in it remain relative to it.
43+
44+
The table `tool.fastapi.cli` will be used. An additional subtable for the
45+
running command will also be used. e.g. `tool.fastapi.cli.dev`. Options
46+
on subcommand tables will override options from the cli table.
47+
48+
Example:
49+
50+
```toml
51+
[tool.fastapi.cli]
52+
path = "asgi.py"
53+
app = "application"
54+
55+
[tool.fastapi.cli.dev]
56+
host = "0.0.0.0"
57+
port = 5000
58+
59+
[tool.fastapi.cli.run]
60+
reload = true
61+
```
62+
"""
63+
64+
toml_keys = ("tool", "fastapi", "cli")
65+
66+
def load_config_table(
67+
self,
68+
ctx: Context,
69+
config: dict[str, Any],
70+
config_path: str | None = None,
71+
) -> None:
72+
if config_path is not None:
73+
config = config.get(config_path, {})
74+
if not config:
75+
return
76+
for param in ctx.command.params:
77+
param_name = param.name or ""
78+
if param_name in config:
79+
try:
80+
value = param.type_cast_value(ctx, config[param_name])
81+
except (TypeError, BadParameter) as e:
82+
keys: list[str] = list(self.toml_keys)
83+
if config_path is not None:
84+
keys.append(config_path)
85+
keys.append(param_name)
86+
full_path = ".".join(keys)
87+
ctx.fail(f"Error parsing pyproject.toml: key '{full_path}': {e}")
88+
else:
89+
ctx.params[param_name] = value
90+
91+
def invoke(self, ctx: Context) -> Any:
92+
config = read_pyproject_file(self.toml_keys)
93+
if config is not None:
94+
logger.info("Loading configuration from pyproject.toml")
95+
command_name = ctx.command.name or ""
96+
self.load_config_table(ctx, config)
97+
self.load_config_table(ctx, config, command_name)
98+
99+
return super().invoke(ctx)

0 commit comments

Comments
 (0)