From 050f0507a42c9690b4d13a77fa0fe9b4aa43e72e Mon Sep 17 00:00:00 2001 From: Patrick Arminio Date: Mon, 22 Dec 2025 17:54:03 +0100 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=94=A7=20Drop=20support=20for=20Pytho?= =?UTF-8?q?n=203.8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test.yml | 4 +--- pyproject.toml | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 21d9ead3..3eed3e03 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,8 +31,6 @@ jobs: os: [ ubuntu-latest, windows-latest, macos-latest ] python-version: ["3.14"] include: - - python-version: "3.8" - os: windows-latest - python-version: "3.9" os: macos-latest - python-version: "3.10" @@ -99,7 +97,7 @@ jobs: - uses: actions/checkout@v6 - uses: actions/setup-python@v6 with: - python-version: '3.8' + python-version: '3.9' - name: Setup uv uses: astral-sh/setup-uv@v7 with: diff --git a/pyproject.toml b/pyproject.toml index b9a0c65e..cf52b1d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ description = "Run and manage FastAPI apps from the command line with FastAPI CL authors = [ {name = "Sebastián Ramírez", email = "tiangolo@gmail.com"}, ] -requires-python = ">=3.8" +requires-python = ">=3.9" readme = "README.md" license = "MIT" license-files = ["LICENSE"] @@ -24,7 +24,6 @@ classifiers = [ "Framework :: FastAPI", "Intended Audience :: Developers", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", From c8f27b3a550472a7d2b7244803169df995c42a2e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 22 Dec 2025 16:56:45 +0000 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=8E=A8=20[pre-commit.ci]=20Auto=20for?= =?UTF-8?q?mat=20from=20pre-commit.com=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pdm_build.py | 6 +++--- src/fastapi_cli/cli.py | 5 ++--- src/fastapi_cli/config.py | 4 ++-- src/fastapi_cli/discover.py | 4 ++-- src/fastapi_cli/utils/cli.py | 4 ++-- tests/conftest.py | 2 +- tests/test_cli_pyproject.py | 21 ++++++++++++--------- tests/utils.py | 3 ++- 8 files changed, 26 insertions(+), 23 deletions(-) diff --git a/pdm_build.py b/pdm_build.py index 67952131..e26d5878 100644 --- a/pdm_build.py +++ b/pdm_build.py @@ -1,5 +1,5 @@ import os -from typing import Any, Dict +from typing import Any from pdm.backend.hooks import Context @@ -9,12 +9,12 @@ def pdm_build_initialize(context: Context): metadata = context.config.metadata # Get custom config for the current package, from the env var - config: Dict[str, Any] = context.config.data["tool"]["tiangolo"][ + config: dict[str, Any] = context.config.data["tool"]["tiangolo"][ "_internal-slim-build" ]["packages"].get(TIANGOLO_BUILD_PACKAGE) if not config: return - project_config: Dict[str, Any] = config["project"] + project_config: dict[str, Any] = config["project"] # Override main [project] configs with custom configs for this package for key, value in project_config.items(): metadata[key] = value diff --git a/src/fastapi_cli/cli.py b/src/fastapi_cli/cli.py index ac2fc791..b4660bde 100644 --- a/src/fastapi_cli/cli.py +++ b/src/fastapi_cli/cli.py @@ -1,12 +1,11 @@ import logging from pathlib import Path -from typing import Any, List, Union +from typing import Annotated, Any, 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 @@ -78,7 +77,7 @@ def callback( setup_logging(level=log_level) -def _get_module_tree(module_paths: List[Path]) -> Tree: +def _get_module_tree(module_paths: list[Path]) -> Tree: root = module_paths[0] name = f"🐍 {root.name}" if root.is_file() else f"📁 {root.name}" diff --git a/src/fastapi_cli/config.py b/src/fastapi_cli/config.py index a2e91d55..f2af18aa 100644 --- a/src/fastapi_cli/config.py +++ b/src/fastapi_cli/config.py @@ -1,6 +1,6 @@ import logging from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Optional from pydantic import BaseModel, StrictStr @@ -11,7 +11,7 @@ class FastAPIConfig(BaseModel): entrypoint: Optional[StrictStr] = None @classmethod - def _read_pyproject_toml(cls) -> Dict[str, Any]: + def _read_pyproject_toml(cls) -> dict[str, Any]: """Read FastAPI configuration from pyproject.toml in current directory.""" pyproject_path = Path.cwd() / "pyproject.toml" diff --git a/src/fastapi_cli/discover.py b/src/fastapi_cli/discover.py index b174f8fb..116ed860 100644 --- a/src/fastapi_cli/discover.py +++ b/src/fastapi_cli/discover.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from logging import getLogger from pathlib import Path -from typing import List, Union +from typing import Union from fastapi_cli.exceptions import FastAPICLIException @@ -39,7 +39,7 @@ def get_default_path() -> Path: class ModuleData: module_import_str: str extra_sys_path: Path - module_paths: List[Path] + module_paths: list[Path] def get_module_data_from_path(path: Path) -> ModuleData: diff --git a/src/fastapi_cli/utils/cli.py b/src/fastapi_cli/utils/cli.py index dff93ee8..6c9c2ac5 100644 --- a/src/fastapi_cli/utils/cli.py +++ b/src/fastapi_cli/utils/cli.py @@ -1,5 +1,5 @@ import logging -from typing import Any, Dict +from typing import Any from rich_toolkit import RichToolkit, RichToolkitTheme from rich_toolkit.styles import TaggedStyle @@ -20,7 +20,7 @@ def formatMessage(self, record: logging.LogRecord) -> str: return result -def get_uvicorn_log_config() -> Dict[str, Any]: +def get_uvicorn_log_config() -> dict[str, Any]: return { "version": 1, "disable_existing_loggers": False, diff --git a/tests/conftest.py b/tests/conftest.py index ee744724..0ad27aee 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,5 @@ import sys -from typing import Generator +from collections.abc import Generator import pytest from typer import rich_utils diff --git a/tests/test_cli_pyproject.py b/tests/test_cli_pyproject.py index c8b072c4..f2b1678f 100644 --- a/tests/test_cli_pyproject.py +++ b/tests/test_cli_pyproject.py @@ -13,9 +13,10 @@ def test_dev_with_pyproject_app_config_uses() -> None: - with changing_dir(assets_path / "pyproject_config"), patch.object( - uvicorn, "run" - ) as mock_run: + 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 @@ -28,9 +29,10 @@ def test_dev_with_pyproject_app_config_uses() -> None: def test_run_with_pyproject_app_config() -> None: - with changing_dir(assets_path / "pyproject_config"), patch.object( - uvicorn, "run" - ) as mock_run: + 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 @@ -43,9 +45,10 @@ def test_run_with_pyproject_app_config() -> None: def test_cli_arg_overrides_pyproject_config() -> None: - with changing_dir(assets_path / "pyproject_config"), patch.object( - uvicorn, "run" - ) as mock_run: + 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 diff --git a/tests/utils.py b/tests/utils.py index 804454cb..c15ed32f 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,7 +1,8 @@ import os +from collections.abc import Generator from contextlib import contextmanager from pathlib import Path -from typing import Generator, Union +from typing import Union @contextmanager From 4b6a82250a6719b3a712b372c786d29494b2e721 Mon Sep 17 00:00:00 2001 From: Patrick Arminio Date: Mon, 22 Dec 2025 17:42:14 +0100 Subject: [PATCH 3/4] =?UTF-8?q?=E2=9C=A8=20Add=20--reload-dir=20option=20t?= =?UTF-8?q?o=20dev=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/fastapi_cli/cli.py | 12 ++++++++++++ tests/test_cli.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/fastapi_cli/cli.py b/src/fastapi_cli/cli.py index b4660bde..3c6a5987 100644 --- a/src/fastapi_cli/cli.py +++ b/src/fastapi_cli/cli.py @@ -104,6 +104,7 @@ def _run( host: str = "127.0.0.1", port: int = 8000, reload: bool = True, + reload_dirs: Union[List[Path], None] = None, workers: Union[int, None] = None, root_path: str = "", command: str, @@ -219,6 +220,10 @@ def _run( host=host, port=port, reload=reload, + reload_dirs=( + [str(directory.resolve()) for directory in reload_dirs] + if reload_dirs else None + ), workers=workers, root_path=root_path, proxy_headers=proxy_headers, @@ -255,6 +260,12 @@ def dev( help="Enable auto-reload of the server when (code) files change. This is [bold]resource intensive[/bold], use it only during development." ), ] = True, + reload_dir: Annotated[ + Union[List[Path], None], + typer.Option( + help="Set reload directories explicitly, instead of using the current working directory." + ), + ] = None, root_path: Annotated[ str, typer.Option( @@ -318,6 +329,7 @@ def dev( host=host, port=port, reload=reload, + reload_dirs=reload_dir, root_path=root_path, app=app, entrypoint=entrypoint, diff --git a/tests/test_cli.py b/tests/test_cli.py index 0a0d7ab1..ddfb808b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -27,6 +27,7 @@ def test_dev() -> None: "host": "127.0.0.1", "port": 8000, "reload": True, + "reload_dirs": None, "workers": None, "root_path": "", "proxy_headers": True, @@ -72,6 +73,7 @@ def test_dev_package() -> None: "host": "127.0.0.1", "port": 8000, "reload": True, + "reload_dirs": None, "workers": None, "root_path": "", "proxy_headers": True, @@ -121,6 +123,7 @@ def test_dev_args() -> None: "host": "192.168.0.2", "port": 8080, "reload": False, + "reload_dirs": None, "workers": None, "root_path": "/api", "proxy_headers": False, @@ -151,6 +154,7 @@ def test_dev_env_vars() -> None: "host": "127.0.0.1", "port": 8111, "reload": True, + "reload_dirs": None, "workers": None, "root_path": "", "proxy_headers": True, @@ -188,6 +192,7 @@ def test_dev_env_vars_and_args() -> None: "host": "127.0.0.1", "port": 8080, "reload": True, + "reload_dirs": None, "workers": None, "root_path": "", "proxy_headers": True, @@ -233,6 +238,7 @@ def test_run() -> None: "host": "0.0.0.0", "port": 8000, "reload": False, + "reload_dirs": None, "workers": None, "root_path": "", "proxy_headers": True, @@ -259,6 +265,7 @@ def test_run_trust_proxy() -> None: "host": "0.0.0.0", "port": 8000, "reload": False, + "reload_dirs": None, "workers": None, "root_path": "", "proxy_headers": True, @@ -305,6 +312,7 @@ def test_run_args() -> None: "host": "192.168.0.2", "port": 8080, "reload": False, + "reload_dirs": None, "workers": 2, "root_path": "/api", "proxy_headers": False, @@ -336,6 +344,7 @@ def test_run_env_vars() -> None: "host": "0.0.0.0", "port": 8111, "reload": False, + "reload_dirs": None, "workers": None, "root_path": "", "proxy_headers": True, @@ -369,6 +378,7 @@ def test_run_env_vars_and_args() -> None: "host": "0.0.0.0", "port": 8080, "reload": False, + "reload_dirs": None, "workers": None, "root_path": "", "proxy_headers": True, @@ -404,6 +414,7 @@ def test_dev_help() -> None: assert "The host to serve on." in result.output assert "The port to serve on." in result.output assert "Enable auto-reload of the server when (code) files change." in result.output + assert "Set reload directories explicitly" in result.output assert "The root path is used to tell your app" in result.output assert "The name of the variable that contains the FastAPI app" in result.output assert "Use multiple worker processes." not in result.output @@ -443,6 +454,30 @@ def test_version() -> None: assert "FastAPI CLI version:" in result.output +def test_dev_reload_dir() -> None: + with changing_dir(assets_path): + with patch.object(uvicorn, "run") as mock_run: + result = runner.invoke( + app, + [ + "dev", + "single_file_app.py", + "--reload-dir", + "src", + "--reload-dir", + "lib", + ], + ) + assert result.exit_code == 0, result.output + assert mock_run.called + assert mock_run.call_args + # Paths are resolved to absolute paths + reload_dirs = mock_run.call_args.kwargs["reload_dirs"] + assert len(reload_dirs) == 2 + assert reload_dirs[0] == str((assets_path / "src").resolve()) + assert reload_dirs[1] == str((assets_path / "lib").resolve()) + + def test_dev_with_import_string() -> None: with changing_dir(assets_path): with patch.object(uvicorn, "run") as mock_run: @@ -456,6 +491,7 @@ def test_dev_with_import_string() -> None: "host": "127.0.0.1", "port": 8000, "reload": True, + "reload_dirs": None, "workers": None, "root_path": "", "proxy_headers": True, @@ -477,6 +513,7 @@ def test_run_with_import_string() -> None: "host": "0.0.0.0", "port": 8000, "reload": False, + "reload_dirs": None, "workers": None, "root_path": "", "proxy_headers": True, From 14a9f0b6521ac209e71d95d0ab035f635798e944 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 22 Dec 2025 16:42:32 +0000 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=8E=A8=20[pre-commit.ci]=20Auto=20for?= =?UTF-8?q?mat=20from=20pre-commit.com=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/fastapi_cli/cli.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/fastapi_cli/cli.py b/src/fastapi_cli/cli.py index 3c6a5987..d000bef5 100644 --- a/src/fastapi_cli/cli.py +++ b/src/fastapi_cli/cli.py @@ -104,7 +104,7 @@ def _run( host: str = "127.0.0.1", port: int = 8000, reload: bool = True, - reload_dirs: Union[List[Path], None] = None, + reload_dirs: Union[list[Path], None] = None, workers: Union[int, None] = None, root_path: str = "", command: str, @@ -222,7 +222,8 @@ def _run( reload=reload, reload_dirs=( [str(directory.resolve()) for directory in reload_dirs] - if reload_dirs else None + if reload_dirs + else None ), workers=workers, root_path=root_path, @@ -261,7 +262,7 @@ def dev( ), ] = True, reload_dir: Annotated[ - Union[List[Path], None], + Union[list[Path], None], typer.Option( help="Set reload directories explicitly, instead of using the current working directory." ),