From: Sebastián Ramírez Date: Wed, 29 Jul 2026 14:31:28 +0000 (+0200) Subject: ✨ Add `app.frontend(check_dir="auto")`, to make local development more convenient... X-Git-Tag: 0.141.0~2 X-Git-Url: http://git.ipfire.org/gitweb.cgi?a=commitdiff_plain;h=5429fed84e84e32672c25a953eca3429b841ce90;p=thirdparty%2Ffastapi%2Ffastapi.git ✨ Add `app.frontend(check_dir="auto")`, to make local development more convenient with `fastapi dev` (#16102) --- diff --git a/docs/en/docs/tutorial/frontend.md b/docs/en/docs/tutorial/frontend.md index 0dacc73ba0..be1bdba185 100644 --- a/docs/en/docs/tutorial/frontend.md +++ b/docs/en/docs/tutorial/frontend.md @@ -106,9 +106,13 @@ Then missing frontend paths return the normal `404`. ## Check Directory { #check-directory } -By default, `app.frontend()` checks that the directory exists when the app is created. +By default, `app.frontend()` uses `check_dir="auto"`. -This helps catch configuration errors early. For example, if the frontend build output directory is missing, **FastAPI** will raise an error on startup. +When the `FASTAPI_ENV` environment variable is set to `development`, **FastAPI** only shows a warning if the frontend build output directory is missing. The [`fastapi dev` command](https://github.com/fastapi/fastapi-cli#fastapi-dev) sets this environment variable for you if it is not already set. This lets you start the backend before building or starting the frontend during development. + +In any other environment, **FastAPI** raises an error when the app is created. This helps catch configuration errors early before deploying an app without its frontend files. + +You can also set `check_dir=True` to always check the directory when the app is created. If your frontend files are created later, for example by a separate build step after the app object is created, set `check_dir=False`: diff --git a/fastapi/applications.py b/fastapi/applications.py index cf55dada46..b5fc76d160 100644 --- a/fastapi/applications.py +++ b/fastapi/applications.py @@ -1247,13 +1247,16 @@ class FastAPI(Starlette): ), ] = "auto", check_dir: Annotated[ - bool, + bool | Literal["auto"], Doc( """ - Check that the frontend directory exists when the app is created. + Check that the frontend directory exists when the app is created. When + set to `"auto"`, skip the check with a warning when `FASTAPI_ENV` is + `"development"`, and check it otherwise. The `fastapi dev` command + sets `FASTAPI_ENV` to `"development"` if it is not already set. """ ), - ] = True, + ] = "auto", ) -> None: """ Serve a static frontend build as low-priority routes. @@ -1285,6 +1288,9 @@ class FastAPI(Starlette): app.frontend("/", directory="dist") ``` """ + check_dir = routing._resolve_frontend_check_dir( + directory=directory, check_dir=check_dir + ) self.router.frontend( path, directory=directory, diff --git a/fastapi/routing.py b/fastapi/routing.py index 5297322678..189dc15163 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -9,6 +9,7 @@ import os import stat import threading import types +import warnings from collections.abc import ( AsyncIterator, Awaitable, @@ -1876,13 +1877,31 @@ def _get_resolved_absolute_path(path: str | os.PathLike[str]) -> str: return os.path.realpath(os.fspath(path)) +def _resolve_frontend_check_dir( + *, + directory: str | os.PathLike[str], + check_dir: bool | Literal["auto"], +) -> bool: + if check_dir != "auto": + return check_dir + if os.environ.get("FASTAPI_ENV") != "development": + return True + if not os.path.isdir(directory): + warnings.warn( + f"Frontend directory '{directory}' does not exist. " + f"Resolved absolute path: '{_get_resolved_absolute_path(directory)}'", + stacklevel=3, + ) + return False + + class _FrontendStaticFiles(StaticFiles): def __init__( self, *, directory: str | os.PathLike[str], fallback: Literal["auto", "index.html", "404.html"] | None, - check_dir: bool = True, + check_dir: bool, ) -> None: self.fallback = fallback if check_dir and not os.path.isdir(directory): @@ -2025,7 +2044,7 @@ class _FrontendRoute(BaseRoute): *, directory: str | os.PathLike[str], fallback: Literal["auto", "index.html", "404.html"] | None = "auto", - check_dir: bool = True, + check_dir: bool, ) -> None: if fallback not in {"auto", "index.html", "404.html", None}: raise AssertionError( @@ -2099,7 +2118,7 @@ class _FrontendRouteGroup(BaseRoute): *, directory: str | os.PathLike[str], fallback: Literal["auto", "index.html", "404.html"] | None = "auto", - check_dir: bool = True, + check_dir: bool, ) -> None: self.routes.append( _FrontendRoute( @@ -2624,13 +2643,16 @@ class APIRouter(routing.Router): ), ] = "auto", check_dir: Annotated[ - bool, + bool | Literal["auto"], Doc( """ - Check that the frontend directory exists when the app is created. + Check that the frontend directory exists when the app is created. When + set to `"auto"`, skip the check with a warning when `FASTAPI_ENV` is + `"development"`, and check it otherwise. The `fastapi dev` command + sets `FASTAPI_ENV` to `"development"` if it is not already set. """ ), - ] = True, + ] = "auto", ) -> None: """ Serve a static frontend build as low-priority routes. @@ -2664,6 +2686,9 @@ class APIRouter(routing.Router): app.include_router(router) ``` """ + check_dir = _resolve_frontend_check_dir( + directory=directory, check_dir=check_dir + ) normalized_path = _normalize_frontend_path(path) if self._frontend_routes is None: self._frontend_routes = _FrontendRouteGroup( diff --git a/pyproject.toml b/pyproject.toml index 2fd2f2e5d1..dcabd9cb72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,7 @@ Changelog = "https://fastapi.tiangolo.com/release-notes/" [project.optional-dependencies] standard = [ - "fastapi-cli[standard] >=0.0.8", + "fastapi-cli[standard] >=0.0.32", "fastar >= 0.9.0", # For the test client "httpx >=0.23.0,<1.0.0", @@ -77,7 +77,7 @@ standard = [ ] standard-no-fastapi-cloud-cli = [ - "fastapi-cli[standard-no-fastapi-cloud-cli] >=0.0.8", + "fastapi-cli[standard-no-fastapi-cloud-cli] >=0.0.32", # For the test client "httpx >=0.23.0,<1.0.0", # For templates @@ -95,7 +95,7 @@ standard-no-fastapi-cloud-cli = [ ] all = [ - "fastapi-cli[standard] >=0.0.8", + "fastapi-cli[standard] >=0.0.32", # # For the test client "httpx >=0.23.0,<1.0.0", # For templates diff --git a/tests/test_frontend.py b/tests/test_frontend.py index d647037e7c..0fd899b94c 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -1176,13 +1176,50 @@ def test_check_dir_true_fails_early_for_missing_directory(monkeypatch, tmp_path: monkeypatch.chdir(tmp_path) with pytest.raises(RuntimeError, match="does not exist") as exc_info: - app.frontend("/", directory="missing") + app.frontend("/", directory="missing", check_dir=True) message = str(exc_info.value) assert "'missing'" in message assert str(tmp_path / "missing") in message +def test_check_dir_auto_warns_in_development(monkeypatch, tmp_path: Path): + monkeypatch.setenv("FASTAPI_ENV", "development") + app = FastAPI() + + with pytest.warns(UserWarning, match="does not exist") as warnings: + app.frontend("/", directory=tmp_path / "missing") + + assert str(tmp_path / "missing") in str(warnings[0].message) + assert warnings[0].filename == __file__ + + +def test_check_dir_auto_router_warning_points_to_user_code(monkeypatch, tmp_path: Path): + monkeypatch.setenv("FASTAPI_ENV", "development") + router = APIRouter() + + with pytest.warns(UserWarning, match="does not exist") as warnings: + router.frontend("/", directory=tmp_path / "missing") + + assert warnings[0].filename == __file__ + + +def test_check_dir_true_fails_in_development(monkeypatch, tmp_path: Path): + monkeypatch.setenv("FASTAPI_ENV", "development") + app = FastAPI() + + with pytest.raises(RuntimeError, match="does not exist"): + app.frontend("/", directory=tmp_path / "missing", check_dir=True) + + +def test_check_dir_auto_fails_outside_development(monkeypatch, tmp_path: Path): + monkeypatch.setenv("FASTAPI_ENV", "production") + router = APIRouter() + + with pytest.raises(RuntimeError, match="does not exist"): + router.frontend("/", directory=tmp_path / "missing") + + def test_check_dir_false_allows_missing_directory_and_fails_on_request(tmp_path: Path): app = FastAPI() app.frontend("/", directory=tmp_path / "missing", check_dir=False) diff --git a/uv.lock b/uv.lock index 5d917c9c16..9844084065 100644 --- a/uv.lock +++ b/uv.lock @@ -1005,9 +1005,9 @@ requires-dist = [ { name = "email-validator", marker = "extra == 'all'", specifier = ">=2.0.0" }, { name = "email-validator", marker = "extra == 'standard'", specifier = ">=2.0.0" }, { name = "email-validator", marker = "extra == 'standard-no-fastapi-cloud-cli'", specifier = ">=2.0.0" }, - { name = "fastapi-cli", extras = ["standard"], marker = "extra == 'all'", specifier = ">=0.0.8" }, - { name = "fastapi-cli", extras = ["standard"], marker = "extra == 'standard'", specifier = ">=0.0.8" }, - { name = "fastapi-cli", extras = ["standard-no-fastapi-cloud-cli"], marker = "extra == 'standard-no-fastapi-cloud-cli'", specifier = ">=0.0.8" }, + { name = "fastapi-cli", extras = ["standard"], marker = "extra == 'all'", specifier = ">=0.0.32" }, + { name = "fastapi-cli", extras = ["standard"], marker = "extra == 'standard'", specifier = ">=0.0.32" }, + { name = "fastapi-cli", extras = ["standard-no-fastapi-cloud-cli"], marker = "extra == 'standard-no-fastapi-cloud-cli'", specifier = ">=0.0.32" }, { name = "fastar", marker = "extra == 'standard'", specifier = ">=0.9.0" }, { name = "httpx", marker = "extra == 'all'", specifier = ">=0.23.0,<1.0.0" }, { name = "httpx", marker = "extra == 'standard'", specifier = ">=0.23.0,<1.0.0" }, @@ -1144,7 +1144,7 @@ translations = [ [[package]] name = "fastapi-cli" -version = "0.0.20" +version = "0.0.32" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "rich-toolkit" }, @@ -1152,9 +1152,9 @@ dependencies = [ { name = "typer" }, { name = "uvicorn", extra = ["standard"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d3/ca/d90fb3bfbcbd6e56c77afd9d114dd6ce8955d8bb90094399d1c70e659e40/fastapi_cli-0.0.20.tar.gz", hash = "sha256:d17c2634f7b96b6b560bc16b0035ed047d523c912011395f49f00a421692bc3a", size = 19786, upload-time = "2025-12-22T17:13:33.794Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/eb/3b534c6f8e157f9ddbf2a153512307c886cad0b258739c200dd8ff8c4452/fastapi_cli-0.0.32.tar.gz", hash = "sha256:38024d2345275e1b37ce8848727a580d84901b570e96b3256d9d36a9a5039424", size = 26636, upload-time = "2026-07-16T12:16:58.678Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/89/5c4eef60524d0fd704eb0706885b82cd5623a43396b94e4a5b17d3a3f516/fastapi_cli-0.0.20-py3-none-any.whl", hash = "sha256:e58b6a0038c0b1532b7a0af690656093dee666201b6b19d3c87175b358e9f783", size = 12390, upload-time = "2025-12-22T17:13:31.708Z" }, + { url = "https://files.pythonhosted.org/packages/d5/53/56ae5ae17bb0a5d89d1d31e5320eb1865553ebbfbde91cdc4c221245f2a8/fastapi_cli-0.0.32-py3-none-any.whl", hash = "sha256:8dcc286fa32f01bbd3f65dd09cfd5a2540ed5f2230b77db7fd30978d6165f3c4", size = 14670, upload-time = "2026-07-16T12:16:57.297Z" }, ] [package.optional-dependencies]