From b306a0171de002eba0408dadf2350dccef8fb3b8 Mon Sep 17 00:00:00 2001 From: Patrick Arminio Date: Fri, 17 Oct 2025 14:27:04 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20support=20for=20reading=20con?= =?UTF-8?q?figuration=20from=20pyproject.toml?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pyproject.toml | 1 + src/fastapi_cli/cli.py | 44 +++++++-- src/fastapi_cli/config.py | 53 ++++++++++ .../assets/pyproject_config/another_module.py | 8 ++ tests/assets/pyproject_config/my_module.py | 8 ++ tests/assets/pyproject_config/pyproject.toml | 2 + tests/test_cli.py | 17 ++++ tests/test_cli_pyproject.py | 97 +++++++++++++++++++ 8 files changed, 224 insertions(+), 6 deletions(-) create mode 100644 src/fastapi_cli/config.py create mode 100644 tests/assets/pyproject_config/another_module.py create mode 100644 tests/assets/pyproject_config/my_module.py create mode 100644 tests/assets/pyproject_config/pyproject.toml create mode 100644 tests/test_cli_pyproject.py diff --git a/pyproject.toml b/pyproject.toml index a04ff960..51e65aea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/src/fastapi_cli/cli.py b/src/fastapi_cli/cli.py index 28afa297..ba403cc7 100644 --- a/src/fastapi_cli/cli.py +++ b/src/fastapi_cli/cli.py @@ -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 @@ -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}") @@ -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() @@ -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, diff --git a/src/fastapi_cli/config.py b/src/fastapi_cli/config.py new file mode 100644 index 00000000..323aad57 --- /dev/null +++ b/src/fastapi_cli/config.py @@ -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) diff --git a/tests/assets/pyproject_config/another_module.py b/tests/assets/pyproject_config/another_module.py new file mode 100644 index 00000000..0aa38c51 --- /dev/null +++ b/tests/assets/pyproject_config/another_module.py @@ -0,0 +1,8 @@ +from fastapi import FastAPI + +app = FastAPI() + + +@app.get("/") +def read_root(): + return {"Hello": "World"} diff --git a/tests/assets/pyproject_config/my_module.py b/tests/assets/pyproject_config/my_module.py new file mode 100644 index 00000000..0aa38c51 --- /dev/null +++ b/tests/assets/pyproject_config/my_module.py @@ -0,0 +1,8 @@ +from fastapi import FastAPI + +app = FastAPI() + + +@app.get("/") +def read_root(): + return {"Hello": "World"} diff --git a/tests/assets/pyproject_config/pyproject.toml b/tests/assets/pyproject_config/pyproject.toml new file mode 100644 index 00000000..c1f1a278 --- /dev/null +++ b/tests/assets/pyproject_config/pyproject.toml @@ -0,0 +1,2 @@ +[tool.fastapi] +entrypoint = "my_module:app" diff --git a/tests/test_cli.py b/tests/test_cli.py index b87a811a..5d06c52f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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: diff --git a/tests/test_cli_pyproject.py b/tests/test_cli_pyproject.py new file mode 100644 index 00000000..c8b072c4 --- /dev/null +++ b/tests/test_cli_pyproject.py @@ -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()