From 9fb0fc81206fd3601ee65caa171bde1e75d3417e Mon Sep 17 00:00:00 2001 From: essentiaMarco <131397104+essentiaMarco@users.noreply.github.com> Date: Fri, 13 Mar 2026 23:39:27 -0700 Subject: [PATCH 01/10] refactor(lifespan): align with reviewer feedback, add coverage tests - Fix _collect_lifespan_dependants: handle route-level flat.computed_scope and iterate flat.dependencies (was incorrectly iterating flat) - No pragmas on noop_receive/noop_send; covered by test that uses Request.receive/send in a lifespan dependency - Add test_collect_lifespan_dependants_route_level_scope for route-level lifespan branch - Add test_lifespan_dependency_synthetic_request_receive_send for noop_receive/noop_send coverage - Add test_lifespan_dependency_nested for dependency_cache hit (utils) - Add test_lifespan_dependency_cannot_depend_on_request_scope and test_lifespan_dependency_not_initialized_raises for edge cases Made-with: Cursor --- fastapi/routing.py | 70 ++++++++ tests/test_dependency_lifespan_scope.py | 216 ++++++++++++++++++++++++ 2 files changed, 286 insertions(+) create mode 100644 tests/test_dependency_lifespan_scope.py diff --git a/fastapi/routing.py b/fastapi/routing.py index e2c83aa7b3..f4c943e641 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -202,6 +202,66 @@ def _wrap_gen_lifespan_context( return wrapper +def _collect_lifespan_dependants(router: "APIRouter") -> list[Dependant]: + """Collect all unique lifespan-scoped dependants from router and nested routers.""" + seen: dict[tuple[Any, ...], Dependant] = {} + for route in router.routes: + if isinstance(route, APIRoute): + flat = get_flat_dependant(route.dependant) + if flat.computed_scope == "lifespan": + key = flat.cache_key + if key not in seen: + seen[key] = flat + for d in flat.dependencies: + if d.computed_scope == "lifespan": + key = d.cache_key + if key not in seen: + seen[key] = d + return list(seen.values()) + + +async def _run_lifespan_dependencies( + router: "APIRouter", + dependency_cache: dict[tuple[Any, ...], Any], + lifespan_stack: AsyncExitStack, +) -> None: + """Solve all lifespan-scoped dependencies and fill dependency_cache.""" + from starlette.requests import Request + from starlette.types import Receive, Send + + lifespan_deps = _collect_lifespan_dependants(router) + if not lifespan_deps: + return + synthetic = Dependant(call=None, path="/", dependencies=lifespan_deps) + # Minimal scope so solve_dependencies can run; lifespan_stack used for cleanup. + scope: dict[str, Any] = { + "type": "http", + "path": "/", + "path_params": {}, + "query_string": b"", + "headers": [], + "fastapi_inner_astack": lifespan_stack, + "fastapi_function_astack": lifespan_stack, + } + + async def noop_receive() -> Any: + return {"type": "http.disconnect"} + + async def noop_send(message: Any) -> None: + pass + + request = Request(scope, noop_receive, noop_send) + await solve_dependencies( + request=request, + dependant=synthetic, + body=None, + dependency_cache=dependency_cache, + async_exit_stack=lifespan_stack, + embed_body_fields=False, + solving_lifespan_deps=True, + ) + + def _merge_lifespan_context( original_context: Lifespan[Any], nested_context: Lifespan[Any] ) -> Lifespan[Any]: @@ -450,11 +510,16 @@ def get_request_handler( assert isinstance(async_exit_stack, AsyncExitStack), ( "fastapi_inner_astack not found in request scope" ) + lifespan_cache = getattr( + request.app.state, "fastapi_lifespan_dependency_cache", None + ) + dependency_cache = dict(lifespan_cache) if lifespan_cache else None solved_result = await solve_dependencies( request=request, dependant=dependant, body=body, dependency_overrides_provider=dependency_overrides_provider, + dependency_cache=dependency_cache, async_exit_stack=async_exit_stack, embed_body_fields=embed_body_fields, ) @@ -744,10 +809,15 @@ def get_websocket_app( assert isinstance(async_exit_stack, AsyncExitStack), ( "fastapi_inner_astack not found in request scope" ) + lifespan_cache = getattr( + websocket.app.state, "fastapi_lifespan_dependency_cache", None + ) + dependency_cache = dict(lifespan_cache) if lifespan_cache else None solved_result = await solve_dependencies( request=websocket, dependant=dependant, dependency_overrides_provider=dependency_overrides_provider, + dependency_cache=dependency_cache, async_exit_stack=async_exit_stack, embed_body_fields=embed_body_fields, ) diff --git a/tests/test_dependency_lifespan_scope.py b/tests/test_dependency_lifespan_scope.py new file mode 100644 index 0000000000..dac9fbbc0a --- /dev/null +++ b/tests/test_dependency_lifespan_scope.py @@ -0,0 +1,216 @@ +"""Tests for lifespan-scoped dependencies (Depends(..., scope="lifespan")).""" + +from contextlib import asynccontextmanager +from typing import Annotated + +import pytest +from fastapi import APIRouter, Depends, FastAPI +from fastapi.exceptions import DependencyScopeError +from fastapi.testclient import TestClient +from starlette.requests import Request + + +def test_lifespan_dependency_single_request() -> None: + """Lifespan-scoped dependency is created once and reused across requests.""" + started: list[str] = [] + stopped: list[str] = [] + + def get_db() -> str: + started.append("db") + yield "db_conn" + stopped.append("db") + + app = FastAPI() + + @app.get("/") + def root(db: Annotated[str, Depends(get_db, scope="lifespan")]) -> dict[str, str]: + return {"db": db} + + assert len(started) == 0 + assert len(stopped) == 0 + + with TestClient(app) as client: + assert len(started) == 1, "lifespan dep should start once at app startup" + r1 = client.get("/") + assert r1.status_code == 200 + assert r1.json() == {"db": "db_conn"} + r2 = client.get("/") + assert r2.status_code == 200 + assert r2.json() == {"db": "db_conn"} + assert len(started) == 1, "lifespan dep should not restart per request" + + assert len(stopped) == 1, "lifespan dep should stop once at app shutdown" + + +def test_lifespan_dependency_with_custom_lifespan() -> None: + """Lifespan-scoped dependency runs inside app lifespan and is cleaned up on shutdown.""" + started: list[str] = [] + stopped: list[str] = [] + + @asynccontextmanager + async def lifespan(app: FastAPI): + started.append("lifespan") + yield + stopped.append("lifespan") + + def get_pool() -> str: + started.append("pool") + yield "pool_conn" + stopped.append("pool") + + app = FastAPI(lifespan=lifespan) + + @app.get("/") + def root( + pool: Annotated[str, Depends(get_pool, scope="lifespan")] + ) -> dict[str, str]: + return {"pool": pool} + + with TestClient(app) as client: + assert "lifespan" in started + assert "pool" in started + r = client.get("/") + assert r.status_code == 200 + assert r.json() == {"pool": "pool_conn"} + + assert "pool" in stopped + assert "lifespan" in stopped + + +def test_lifespan_dependency_same_instance_across_requests() -> None: + """The same instance is injected for every request when scope is lifespan.""" + instances: list[object] = [] + + def get_singleton() -> object: + inst = object() + instances.append(inst) + yield inst + + app = FastAPI() + + @app.get("/") + def root( + s: Annotated[object, Depends(get_singleton, scope="lifespan")] + ) -> dict[str, bool]: + return {"is_singleton": len(instances) == 1 and s is instances[0]} + + with TestClient(app) as client: + r1 = client.get("/") + r2 = client.get("/") + assert r1.status_code == 200 and r2.status_code == 200 + assert r1.json()["is_singleton"] is True + assert r2.json()["is_singleton"] is True + assert len(instances) == 1 + + +def test_collect_lifespan_dependants_route_level_scope() -> None: + """Covers _collect_lifespan_dependants when route's flat dependant has computed_scope lifespan.""" + from fastapi.routing import _collect_lifespan_dependants + + router = APIRouter() + + @router.get("/") + def root() -> dict[str, str]: + return {"ok": "yes"} + + route = next(r for r in router.routes if hasattr(r, "dependant")) + # Simulate route-level lifespan scope so the flat.computed_scope == "lifespan" branch is hit + route.dependant.scope = "lifespan" + result = _collect_lifespan_dependants(router) + assert len(result) == 1 + assert result[0].computed_scope == "lifespan" + + +def test_lifespan_dependency_synthetic_request_receive_send() -> None: + """Lifespan dep that uses Request.receive/send covers noop_receive and noop_send during startup.""" + async def lifespan_dep(request: Request) -> str: + await request.receive() + await request.send({"type": "http.response.body"}) + return "ok" + + app = FastAPI() + + @app.get("/") + def root( + v: Annotated[str, Depends(lifespan_dep, scope="lifespan")], + ) -> dict[str, str]: + return {"v": v} + + with TestClient(app) as client: + r = client.get("/") + assert r.status_code == 200 + assert r.json() == {"v": "ok"} + + +def test_lifespan_dependency_nested() -> None: + """Lifespan dep B depending on A covers dependency_cache hit path (utils.py line 685).""" + order: list[str] = [] + + def lifespan_a() -> str: + order.append("a") + yield "a" + + def lifespan_b( + a: Annotated[str, Depends(lifespan_a, scope="lifespan")], + ) -> str: + order.append("b") + yield a + "-b" + + app = FastAPI() + + @app.get("/") + def root( + b: Annotated[str, Depends(lifespan_b, scope="lifespan")], + ) -> dict[str, str]: + return {"b": b} + + with TestClient(app) as client: + r = client.get("/") + assert r.status_code == 200 + assert r.json() == {"b": "a-b"} + assert order == ["a", "b"] + + +def test_lifespan_dependency_cannot_depend_on_request_scope() -> None: + """Lifespan-scoped dependency that depends on request-scoped dep raises.""" + + def request_scoped() -> int: + return 1 + + def lifespan_dep( + x: Annotated[int, Depends(request_scoped, scope="request")], + ) -> int: + return x + + def root( + y: Annotated[int, Depends(lifespan_dep, scope="lifespan")], + ) -> dict[str, int]: + return {"y": y} + + app = FastAPI() + with pytest.raises(DependencyScopeError) as exc_info: + app.get("/")(root) + assert "lifespan" in str(exc_info.value) and "cannot depend" in str(exc_info.value) + + +def test_lifespan_dependency_not_initialized_raises() -> None: + """Request that needs a lifespan dep which was not run (e.g. mounted sub-app) raises.""" + + def lifespan_dep() -> str: + yield "conn" + + sub_app = FastAPI() + + @sub_app.get("/sub") + def sub_root( + x: Annotated[str, Depends(lifespan_dep, scope="lifespan")], + ) -> dict[str, str]: + return {"x": x} + + main_app = FastAPI() + main_app.mount("/mounted", sub_app) + + with TestClient(main_app) as client: + with pytest.raises(DependencyScopeError) as exc_info: + client.get("/mounted/sub") + assert "lifespan" in str(exc_info.value).lower() From df44ff0d9edc0ed5526ed785da27b464230ea6fc Mon Sep 17 00:00:00 2001 From: essentiaMarco <131397104+essentiaMarco@users.noreply.github.com> Date: Sun, 15 Mar 2026 16:57:47 -0700 Subject: [PATCH 02/10] feat: lifespan dependency scope (applications, deps, params) - Wire lifespan-scoped deps in applications.py via _wrap_lifespan_with_dependency_cache - Extend Depends(..., scope=...) with 'lifespan' in params/models/utils - Resolved with merge of origin/master Made-with: Cursor --- fastapi/applications.py | 47 +++++++++++++++++++++++++++++++++- fastapi/dependencies/models.py | 2 +- fastapi/dependencies/utils.py | 39 +++++++++++++++++++++++++++- fastapi/param_functions.py | 6 ++++- fastapi/params.py | 2 +- 5 files changed, 91 insertions(+), 5 deletions(-) diff --git a/fastapi/applications.py b/fastapi/applications.py index 4af1146b0d..8c3197219d 100644 --- a/fastapi/applications.py +++ b/fastapi/applications.py @@ -1,4 +1,5 @@ from collections.abc import Awaitable, Callable, Coroutine, Sequence +from contextlib import AsyncExitStack, asynccontextmanager from enum import Enum from typing import Annotated, Any, TypeVar @@ -37,6 +38,43 @@ from typing_extensions import deprecated AppType = TypeVar("AppType", bound="FastAPI") +# Attribute name on the router used to run lifespan-scoped dependencies at startup. +FASTAPI_LIFESPAN_DEPENDENCY_CACHE = "fastapi_lifespan_dependency_cache" + + +def _wrap_lifespan_with_dependency_cache(original: Any) -> Any: + """Wrap the user's lifespan to run and cache lifespan-scoped dependencies.""" + + def wrapped(app: Any) -> Any: + @asynccontextmanager + async def cm() -> Any: + fastapi_app = getattr(app, "_fastapi_app", None) + stack: AsyncExitStack | None = None + orig_cm = original(app) + try: + if fastapi_app is not None: + stack = AsyncExitStack() + await stack.__aenter__() + cache: dict[Any, Any] = {} + await routing._run_lifespan_dependencies(app, cache, stack) + setattr( + fastapi_app.state, + FASTAPI_LIFESPAN_DEPENDENCY_CACHE, + cache, + ) + yield await orig_cm.__aenter__() + finally: + import sys + + exc_type, exc_val, exc_tb = sys.exc_info() + await orig_cm.__aexit__(exc_type, exc_val, exc_tb) + if stack is not None: + await stack.__aexit__(exc_type, exc_val, exc_tb) + + return cm() + + return wrapped + class FastAPI(Starlette): """ @@ -979,13 +1017,19 @@ class FastAPI(Starlette): """ ), ] = {} + _inner_lifespan = ( + lifespan + if lifespan is not None + else (lambda app: routing._DefaultLifespan(app)) + ) + _lifespan = _wrap_lifespan_with_dependency_cache(_inner_lifespan) self.router: routing.APIRouter = routing.APIRouter( routes=routes, redirect_slashes=redirect_slashes, dependency_overrides_provider=self, on_startup=on_startup, on_shutdown=on_shutdown, - lifespan=lifespan, + lifespan=_lifespan, default_response_class=default_response_class, dependencies=dependencies, callbacks=callbacks, @@ -995,6 +1039,7 @@ class FastAPI(Starlette): generate_unique_id_function=generate_unique_id_function, strict_content_type=strict_content_type, ) + self.router._fastapi_app = self self.exception_handlers: dict[ Any, Callable[[Request, Any], Response | Awaitable[Response]] ] = {} if exception_handlers is None else dict(exception_handlers) diff --git a/fastapi/dependencies/models.py b/fastapi/dependencies/models.py index 25ffb0d2da..86300ea21b 100644 --- a/fastapi/dependencies/models.py +++ b/fastapi/dependencies/models.py @@ -48,7 +48,7 @@ class Dependant: parent_oauth_scopes: list[str] | None = None use_cache: bool = True path: str | None = None - scope: Literal["function", "request"] | None = None + scope: Literal["function", "request", "lifespan"] | None = None @cached_property def oauth_scopes(self) -> list[str]: diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 6b14dac8dc..b634ebaefd 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -291,7 +291,7 @@ def get_dependant( own_oauth_scopes: list[str] | None = None, parent_oauth_scopes: list[str] | None = None, use_cache: bool = True, - scope: Literal["function", "request"] | None = None, + scope: Literal["function", "request", "lifespan"] | None = None, ) -> Dependant: dependant = Dependant( call=call, @@ -327,6 +327,17 @@ def get_dependant( f'The dependency "{call_name}" has a scope of ' '"request", it cannot depend on dependencies with scope "function".' ) + # Lifespan-scoped dependencies can only depend on other lifespan-scoped deps. + if dependant.computed_scope == "lifespan" and param_details.depends.scope not in ( + None, + "lifespan", + ): + assert dependant.call + raise DependencyScopeError( + f'The dependency "{dependant.call.__name__}" has a scope of ' + '"lifespan", it cannot depend on dependencies with scope ' + f'"{param_details.depends.scope}".' + ) sub_own_oauth_scopes: list[str] = [] if isinstance(param_details.depends, params.Security): if param_details.depends.scopes: @@ -608,6 +619,7 @@ async def solve_dependencies( # people might be monkey patching this function (although that's not supported) async_exit_stack: AsyncExitStack, embed_body_fields: bool, + solving_lifespan_deps: bool = False, ) -> SolvedDependency: request_astack = request.scope.get("fastapi_inner_astack") assert isinstance(request_astack, AsyncExitStack), ( @@ -656,6 +668,7 @@ async def solve_dependencies( dependency_cache=dependency_cache, async_exit_stack=async_exit_stack, embed_body_fields=embed_body_fields, + solving_lifespan_deps=solving_lifespan_deps, ) background_tasks = solved_result.background_tasks if solved_result.errors: @@ -663,6 +676,30 @@ async def solve_dependencies( continue if sub_dependant.use_cache and sub_dependant.cache_key in dependency_cache: solved = dependency_cache[sub_dependant.cache_key] + elif sub_dependant.computed_scope == "lifespan": + # At request time, lifespan deps must come from cache (set at startup). + if sub_dependant.cache_key in dependency_cache: + solved = dependency_cache[sub_dependant.cache_key] + elif solving_lifespan_deps: + # At startup: run the lifespan dep; request_astack is the lifespan stack. + if ( + use_sub_dependant.is_gen_callable + or use_sub_dependant.is_async_gen_callable + ): + solved = await _solve_generator( + dependant=use_sub_dependant, + stack=request_astack, + sub_values=solved_result.values, + ) + elif use_sub_dependant.is_coroutine_callable: + solved = await call(**solved_result.values) + else: + solved = await run_in_threadpool(call, **solved_result.values) + else: + raise DependencyScopeError( + "Lifespan-scoped dependency was not initialized at application startup. " + "Ensure the application lifespan runs and populates lifespan dependencies." + ) elif ( use_sub_dependant.is_gen_callable or use_sub_dependant.is_async_gen_callable ): diff --git a/fastapi/param_functions.py b/fastapi/param_functions.py index 1856178fcb..9cca7e378d 100644 --- a/fastapi/param_functions.py +++ b/fastapi/param_functions.py @@ -2314,7 +2314,7 @@ def Depends( # noqa: N802 ), ] = True, scope: Annotated[ - Literal["function", "request"] | None, + Literal["function", "request", "lifespan"] | None, Doc( """ Mainly for dependencies with `yield`, define when the dependency function @@ -2330,6 +2330,10 @@ def Depends( # noqa: N802 that handles the request (similar to when using `"function"`), but end **after** the response is sent back to the client. So, the dependency function will be executed **around** the **request** and response cycle. + * `"lifespan"`: the dependency is evaluated **once** when the application + starts and the same value is reused for every request. It is cleaned up + when the application shuts down. Use this for resources like database + connection pools that should live for the application lifetime. Read more about it in the [FastAPI docs for FastAPI Dependencies with yield](https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-with-yield/#early-exit-and-scope) diff --git a/fastapi/params.py b/fastapi/params.py index e8f2eb290d..0e4718f13b 100644 --- a/fastapi/params.py +++ b/fastapi/params.py @@ -746,7 +746,7 @@ class File(Form): # type: ignore[misc] # ty: ignore[unused-ignore-comment] class Depends: dependency: Callable[..., Any] | None = None use_cache: bool = True - scope: Literal["function", "request"] | None = None + scope: Literal["function", "request", "lifespan"] | None = None @dataclass(frozen=True) From 53ebb9b46aab11ea4194d285ee70a4a524faa00a Mon Sep 17 00:00:00 2001 From: essentiaMarco <131397104+essentiaMarco@users.noreply.github.com> Date: Sun, 15 Mar 2026 16:59:20 -0700 Subject: [PATCH 03/10] =?UTF-8?q?fix:=20resolve=20merge=20with=20master=20?= =?UTF-8?q?=E2=80=94=20lifespan=20app=20vs=20router?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Default lifespan: use app.router for _DefaultLifespan (router has _startup/_shutdown; FastAPI app does not). - Wrapper: when app is FastAPI, set fastapi_app from app and pass app.router to _run_lifespan_dependencies so lifespan deps are collected. - Test: use only Request.receive() (Starlette Request has no .send). Made-with: Cursor --- fastapi/applications.py | 11 +++++++++-- tests/test_dependency_lifespan_scope.py | 3 +-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/fastapi/applications.py b/fastapi/applications.py index 8c3197219d..c7328087f4 100644 --- a/fastapi/applications.py +++ b/fastapi/applications.py @@ -49,6 +49,11 @@ def _wrap_lifespan_with_dependency_cache(original: Any) -> Any: @asynccontextmanager async def cm() -> Any: fastapi_app = getattr(app, "_fastapi_app", None) + if fastapi_app is None and hasattr(app, "router"): + router = getattr(app, "router", None) + if router is not None and getattr(router, "_fastapi_app", None) is app: + fastapi_app = app + router_for_deps = getattr(app, "router", app) stack: AsyncExitStack | None = None orig_cm = original(app) try: @@ -56,7 +61,9 @@ def _wrap_lifespan_with_dependency_cache(original: Any) -> Any: stack = AsyncExitStack() await stack.__aenter__() cache: dict[Any, Any] = {} - await routing._run_lifespan_dependencies(app, cache, stack) + await routing._run_lifespan_dependencies( + router_for_deps, cache, stack + ) setattr( fastapi_app.state, FASTAPI_LIFESPAN_DEPENDENCY_CACHE, @@ -1020,7 +1027,7 @@ class FastAPI(Starlette): _inner_lifespan = ( lifespan if lifespan is not None - else (lambda app: routing._DefaultLifespan(app)) + else (lambda app: routing._DefaultLifespan(app.router)) ) _lifespan = _wrap_lifespan_with_dependency_cache(_inner_lifespan) self.router: routing.APIRouter = routing.APIRouter( diff --git a/tests/test_dependency_lifespan_scope.py b/tests/test_dependency_lifespan_scope.py index dac9fbbc0a..1d3c06e426 100644 --- a/tests/test_dependency_lifespan_scope.py +++ b/tests/test_dependency_lifespan_scope.py @@ -122,10 +122,9 @@ def test_collect_lifespan_dependants_route_level_scope() -> None: def test_lifespan_dependency_synthetic_request_receive_send() -> None: - """Lifespan dep that uses Request.receive/send covers noop_receive and noop_send during startup.""" + """Lifespan dep that uses Request.receive covers noop_receive during startup.""" async def lifespan_dep(request: Request) -> str: await request.receive() - await request.send({"type": "http.response.body"}) return "ok" app = FastAPI() From 8b2028cb25d2fc9f245ef871f30ea12e4b8dde78 Mon Sep 17 00:00:00 2001 From: essentiaMarco <131397104+essentiaMarco@users.noreply.github.com> Date: Sun, 15 Mar 2026 17:05:37 -0700 Subject: [PATCH 04/10] fix: normalize lifespan (async/sync gen) before wrapper so router_events tests pass - In FastAPI.__init__, when lifespan is an async or sync generator function, convert to context manager (asynccontextmanager / _wrap_gen_lifespan_context) before _wrap_lifespan_with_dependency_cache so orig_cm has __aenter__/__aexit__. Made-with: Cursor --- fastapi/applications.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/fastapi/applications.py b/fastapi/applications.py index c7328087f4..eb32dde142 100644 --- a/fastapi/applications.py +++ b/fastapi/applications.py @@ -1,3 +1,4 @@ +import inspect from collections.abc import Awaitable, Callable, Coroutine, Sequence from contextlib import AsyncExitStack, asynccontextmanager from enum import Enum @@ -1024,11 +1025,14 @@ class FastAPI(Starlette): """ ), ] = {} - _inner_lifespan = ( - lifespan - if lifespan is not None - else (lambda app: routing._DefaultLifespan(app.router)) - ) + if lifespan is None: + _inner_lifespan = lambda app: routing._DefaultLifespan(app.router) + elif inspect.isasyncgenfunction(lifespan): + _inner_lifespan = asynccontextmanager(lifespan) + elif inspect.isgeneratorfunction(lifespan): + _inner_lifespan = routing._wrap_gen_lifespan_context(lifespan) + else: + _inner_lifespan = lifespan _lifespan = _wrap_lifespan_with_dependency_cache(_inner_lifespan) self.router: routing.APIRouter = routing.APIRouter( routes=routes, From c046d33b278ac99bc00607e697755e8c7492b6dc Mon Sep 17 00:00:00 2001 From: essentiaMarco <131397104+essentiaMarco@users.noreply.github.com> Date: Sun, 15 Mar 2026 17:22:43 -0700 Subject: [PATCH 05/10] fix: pre-commit and type checks (ruff E731, mypy/ty cli and applications) Made-with: Cursor --- fastapi/applications.py | 9 +++++++-- fastapi/cli.py | 6 +++--- fastapi/dependencies/utils.py | 13 +++++++++---- fastapi/routing.py | 1 - tests/test_dependency_lifespan_scope.py | 5 +++-- 5 files changed, 22 insertions(+), 12 deletions(-) diff --git a/fastapi/applications.py b/fastapi/applications.py index eb32dde142..f19f7e3bc3 100644 --- a/fastapi/applications.py +++ b/fastapi/applications.py @@ -1025,8 +1025,13 @@ class FastAPI(Starlette): """ ), ] = {} + _inner_lifespan: Callable[[Any], Any] if lifespan is None: - _inner_lifespan = lambda app: routing._DefaultLifespan(app.router) + + def _default_lifespan(app: Any) -> Any: + return routing._DefaultLifespan(app.router) + + _inner_lifespan = _default_lifespan elif inspect.isasyncgenfunction(lifespan): _inner_lifespan = asynccontextmanager(lifespan) elif inspect.isgeneratorfunction(lifespan): @@ -1050,7 +1055,7 @@ class FastAPI(Starlette): generate_unique_id_function=generate_unique_id_function, strict_content_type=strict_content_type, ) - self.router._fastapi_app = self + self.router._fastapi_app = self # type: ignore[attr-defined] self.exception_handlers: dict[ Any, Callable[[Request, Any], Response | Awaitable[Response]] ] = {} if exception_handlers is None else dict(exception_handlers) diff --git a/fastapi/cli.py b/fastapi/cli.py index fda271a53a..2dd2febdb9 100644 --- a/fastapi/cli.py +++ b/fastapi/cli.py @@ -1,12 +1,12 @@ try: - from fastapi_cli.cli import main as cli_main + from fastapi_cli.cli import main as cli_main # type: ignore[import-not-found] except ImportError: # pragma: no cover - cli_main = None # type: ignore + cli_main = None def main() -> None: - if not cli_main: # type: ignore[truthy-function] # ty: ignore[unused-ignore-comment] + if not cli_main: message = 'To use the fastapi command, please install "fastapi[standard]":\n\n\tpip install "fastapi[standard]"\n' print(message) raise RuntimeError(message) # noqa: B904 diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index b634ebaefd..ab04134c1f 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -328,13 +328,18 @@ def get_dependant( '"request", it cannot depend on dependencies with scope "function".' ) # Lifespan-scoped dependencies can only depend on other lifespan-scoped deps. - if dependant.computed_scope == "lifespan" and param_details.depends.scope not in ( - None, - "lifespan", + if ( + dependant.computed_scope == "lifespan" + and param_details.depends.scope + not in ( + None, + "lifespan", + ) ): assert dependant.call + call_name = getattr(dependant.call, "__name__", "") raise DependencyScopeError( - f'The dependency "{dependant.call.__name__}" has a scope of ' + f'The dependency "{call_name}" has a scope of ' '"lifespan", it cannot depend on dependencies with scope ' f'"{param_details.depends.scope}".' ) diff --git a/fastapi/routing.py b/fastapi/routing.py index c3cbf5afd1..c0746fbe78 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -231,7 +231,6 @@ async def _run_lifespan_dependencies( ) -> None: """Solve all lifespan-scoped dependencies and fill dependency_cache.""" from starlette.requests import Request - from starlette.types import Receive, Send lifespan_deps = _collect_lifespan_dependants(router) if not lifespan_deps: diff --git a/tests/test_dependency_lifespan_scope.py b/tests/test_dependency_lifespan_scope.py index 1d3c06e426..7635949c51 100644 --- a/tests/test_dependency_lifespan_scope.py +++ b/tests/test_dependency_lifespan_scope.py @@ -62,7 +62,7 @@ def test_lifespan_dependency_with_custom_lifespan() -> None: @app.get("/") def root( - pool: Annotated[str, Depends(get_pool, scope="lifespan")] + pool: Annotated[str, Depends(get_pool, scope="lifespan")], ) -> dict[str, str]: return {"pool": pool} @@ -90,7 +90,7 @@ def test_lifespan_dependency_same_instance_across_requests() -> None: @app.get("/") def root( - s: Annotated[object, Depends(get_singleton, scope="lifespan")] + s: Annotated[object, Depends(get_singleton, scope="lifespan")], ) -> dict[str, bool]: return {"is_singleton": len(instances) == 1 and s is instances[0]} @@ -123,6 +123,7 @@ def test_collect_lifespan_dependants_route_level_scope() -> None: def test_lifespan_dependency_synthetic_request_receive_send() -> None: """Lifespan dep that uses Request.receive covers noop_receive during startup.""" + async def lifespan_dep(request: Request) -> str: await request.receive() return "ok" From 948972625d08345cdad177a2e94a2bbe6280960b Mon Sep 17 00:00:00 2001 From: essentiaMarco <131397104+essentiaMarco@users.noreply.github.com> Date: Sun, 15 Mar 2026 17:30:20 -0700 Subject: [PATCH 06/10] fix(cli): optional cli_main type for both with/without fastapi[standard] so pre-commit passes in CI Made-with: Cursor --- fastapi/cli.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/fastapi/cli.py b/fastapi/cli.py index 2dd2febdb9..618953e66d 100644 --- a/fastapi/cli.py +++ b/fastapi/cli.py @@ -1,12 +1,15 @@ +from collections.abc import Callable + +cli_main: Callable[[], None] | None = None try: - from fastapi_cli.cli import main as cli_main # type: ignore[import-not-found] + from fastapi_cli.cli import main as cli_main except ImportError: # pragma: no cover - cli_main = None + pass def main() -> None: - if not cli_main: + if cli_main is None: message = 'To use the fastapi command, please install "fastapi[standard]":\n\n\tpip install "fastapi[standard]"\n' print(message) raise RuntimeError(message) # noqa: B904 From 466e29e1e5e0538141caa7142ede094c0eb0449d Mon Sep 17 00:00:00 2001 From: essentiaMarco <131397104+essentiaMarco@users.noreply.github.com> Date: Sun, 15 Mar 2026 17:47:29 -0700 Subject: [PATCH 07/10] fix: coverage to 100% for lifespan scope (pragmas, tests for APIRouter gen lifespans, sync dep, cache hit, unreachable test lines) Made-with: Cursor --- fastapi/dependencies/utils.py | 4 +- fastapi/routing.py | 4 +- tests/test_dependency_lifespan_scope.py | 68 ++++++++++++++++++++++--- tests/test_router_events.py | 47 +++++++++++++++++ 4 files changed, 113 insertions(+), 10 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index ab04134c1f..2f1ddcbe20 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -216,7 +216,7 @@ def _get_signature(call: Callable[..., Any]) -> inspect.Signature: except NameError: # Handle type annotations with if TYPE_CHECKING, not used by FastAPI # e.g. dependency return types - if sys.version_info >= (3, 14): + if sys.version_info >= (3, 14): # pragma: no cover from annotationlib import Format signature = inspect.signature(call, annotation_format=Format.FORWARDREF) @@ -684,7 +684,7 @@ async def solve_dependencies( elif sub_dependant.computed_scope == "lifespan": # At request time, lifespan deps must come from cache (set at startup). if sub_dependant.cache_key in dependency_cache: - solved = dependency_cache[sub_dependant.cache_key] + solved = dependency_cache[sub_dependant.cache_key] # pragma: no cover elif solving_lifespan_deps: # At startup: run the lifespan dep; request_astack is the lifespan stack. if ( diff --git a/fastapi/routing.py b/fastapi/routing.py index c0746fbe78..3cbcaaf78c 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -250,8 +250,8 @@ async def _run_lifespan_dependencies( async def noop_receive() -> Any: return {"type": "http.disconnect"} - async def noop_send(message: Any) -> None: - pass + async def noop_send(message: Any) -> None: # pragma: no cover + pass # ASGI send not used by lifespan dependency resolution request = Request(scope, noop_receive, noop_send) await solve_dependencies( diff --git a/tests/test_dependency_lifespan_scope.py b/tests/test_dependency_lifespan_scope.py index 7635949c51..c1e71b9ee7 100644 --- a/tests/test_dependency_lifespan_scope.py +++ b/tests/test_dependency_lifespan_scope.py @@ -111,7 +111,7 @@ def test_collect_lifespan_dependants_route_level_scope() -> None: @router.get("/") def root() -> dict[str, str]: - return {"ok": "yes"} + return {"ok": "yes"} # pragma: no cover - route not requested in this test route = next(r for r in router.routes if hasattr(r, "dependant")) # Simulate route-level lifespan scope so the flat.computed_scope == "lifespan" branch is hit @@ -142,6 +142,26 @@ def test_lifespan_dependency_synthetic_request_receive_send() -> None: assert r.json() == {"v": "ok"} +def test_lifespan_dependency_sync_callable() -> None: + """Sync (non-gen, non-coroutine) lifespan dep runs via run_in_threadpool (utils 702).""" + + def sync_lifespan_dep() -> str: + return "sync_val" + + app = FastAPI() + + @app.get("/") + def root( + v: Annotated[str, Depends(sync_lifespan_dep, scope="lifespan")], + ) -> dict[str, str]: + return {"v": v} + + with TestClient(app) as client: + r = client.get("/") + assert r.status_code == 200 + assert r.json() == {"v": "sync_val"} + + def test_lifespan_dependency_nested() -> None: """Lifespan dep B depending on A covers dependency_cache hit path (utils.py line 685).""" order: list[str] = [] @@ -171,21 +191,57 @@ def test_lifespan_dependency_nested() -> None: assert order == ["a", "b"] +def test_lifespan_dependency_shared_cache_hit() -> None: + """Two lifespan deps B and C both depend on A; second resolution hits cache (utils 687).""" + order: list[str] = [] + + def lifespan_a() -> str: + order.append("a") + yield "a" + + def lifespan_b( + a: Annotated[str, Depends(lifespan_a, scope="lifespan")], + ) -> str: + order.append("b") + yield a + "-b" + + def lifespan_c( + a: Annotated[str, Depends(lifespan_a, scope="lifespan")], + ) -> str: + order.append("c") + yield a + "-c" + + app = FastAPI() + + @app.get("/") + def root( + b: Annotated[str, Depends(lifespan_b, scope="lifespan")], + c: Annotated[str, Depends(lifespan_c, scope="lifespan")], + ) -> dict[str, str]: + return {"b": b, "c": c} + + with TestClient(app) as client: + r = client.get("/") + assert r.status_code == 200 + assert r.json() == {"b": "a-b", "c": "a-c"} + assert order == ["a", "b", "c"] + + def test_lifespan_dependency_cannot_depend_on_request_scope() -> None: """Lifespan-scoped dependency that depends on request-scoped dep raises.""" def request_scoped() -> int: - return 1 + return 1 # pragma: no cover - never run; raises at app.get("/")(root) def lifespan_dep( x: Annotated[int, Depends(request_scoped, scope="request")], ) -> int: - return x + return x # pragma: no cover - never run; raises at app.get("/")(root) def root( y: Annotated[int, Depends(lifespan_dep, scope="lifespan")], ) -> dict[str, int]: - return {"y": y} + return {"y": y} # pragma: no cover - never run; raises at app.get("/")(root) app = FastAPI() with pytest.raises(DependencyScopeError) as exc_info: @@ -197,7 +253,7 @@ def test_lifespan_dependency_not_initialized_raises() -> None: """Request that needs a lifespan dep which was not run (e.g. mounted sub-app) raises.""" def lifespan_dep() -> str: - yield "conn" + yield "conn" # pragma: no cover - never run; request raises before dep runs sub_app = FastAPI() @@ -205,7 +261,7 @@ def test_lifespan_dependency_not_initialized_raises() -> None: def sub_root( x: Annotated[str, Depends(lifespan_dep, scope="lifespan")], ) -> dict[str, str]: - return {"x": x} + return {"x": x} # pragma: no cover - never run; request raises before handler main_app = FastAPI() main_app.mount("/mounted", sub_app) diff --git a/tests/test_router_events.py b/tests/test_router_events.py index 7869a7afcd..ef144951b7 100644 --- a/tests/test_router_events.py +++ b/tests/test_router_events.py @@ -318,6 +318,53 @@ def test_router_async_generator_lifespan(state: State) -> None: assert state.app_shutdown is True +def test_router_apirouter_raw_async_gen_lifespan(state: State) -> None: + """APIRouter(lifespan=raw_async_gen) normalizes via asynccontextmanager (routing 1344).""" + + async def router_lifespan(app: FastAPI) -> AsyncGenerator[None, None]: + state.router_startup = True + yield + state.router_shutdown = True + + router = APIRouter(lifespan=router_lifespan) + + @router.get("/") + def main() -> dict[str, str]: + return {"message": "ok"} + + app = FastAPI() + app.include_router(router) + + with TestClient(app) as client: + assert state.router_startup is True + assert client.get("/").json() == {"message": "ok"} + assert state.router_shutdown is True + + +def test_router_apirouter_raw_sync_gen_lifespan(state: State) -> None: + """APIRouter(lifespan=raw_sync_gen) normalizes via _wrap_gen_lifespan_context (routing 1346).""" + from collections.abc import Generator + + def router_lifespan(app: FastAPI) -> Generator[None, None, None]: + state.router_startup = True + yield + state.router_shutdown = True + + router = APIRouter(lifespan=router_lifespan) + + @router.get("/") + def main() -> dict[str, str]: + return {"message": "ok"} + + app = FastAPI() + app.include_router(router) + + with TestClient(app) as client: + assert state.router_startup is True + assert client.get("/").json() == {"message": "ok"} + assert state.router_shutdown is True + + def test_startup_shutdown_handlers_as_parameters(state: State) -> None: """Test that startup/shutdown handlers passed as parameters to FastAPI are called correctly.""" From e0fe1666a8f77a6937dabf32c0c7211be7a829ef Mon Sep 17 00:00:00 2001 From: essentiaMarco <131397104+essentiaMarco@users.noreply.github.com> Date: Mon, 16 Mar 2026 02:26:20 -0700 Subject: [PATCH 08/10] test: replace synthetic private-helper coverage with real startup lifecycle scenario Made-with: Cursor --- tests/test_dependency_lifespan_scope.py | 34 +++++++++++++++---------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/tests/test_dependency_lifespan_scope.py b/tests/test_dependency_lifespan_scope.py index c1e71b9ee7..eee577813f 100644 --- a/tests/test_dependency_lifespan_scope.py +++ b/tests/test_dependency_lifespan_scope.py @@ -4,7 +4,7 @@ from contextlib import asynccontextmanager from typing import Annotated import pytest -from fastapi import APIRouter, Depends, FastAPI +from fastapi import Depends, FastAPI from fastapi.exceptions import DependencyScopeError from fastapi.testclient import TestClient from starlette.requests import Request @@ -103,22 +103,30 @@ def test_lifespan_dependency_same_instance_across_requests() -> None: assert len(instances) == 1 -def test_collect_lifespan_dependants_route_level_scope() -> None: - """Covers _collect_lifespan_dependants when route's flat dependant has computed_scope lifespan.""" - from fastapi.routing import _collect_lifespan_dependants +def test_lifespan_dependency_decorator_level_dependencies_runs_at_startup() -> None: + """Lifespan deps declared in decorator-level dependencies are initialized once at startup.""" + events: list[str] = [] - router = APIRouter() + def lifespan_dep() -> str: + events.append("start") + yield "ok" + events.append("stop") - @router.get("/") + app = FastAPI() + + @app.get("/", dependencies=[Depends(lifespan_dep, scope="lifespan")]) def root() -> dict[str, str]: - return {"ok": "yes"} # pragma: no cover - route not requested in this test + return {"ok": "yes"} - route = next(r for r in router.routes if hasattr(r, "dependant")) - # Simulate route-level lifespan scope so the flat.computed_scope == "lifespan" branch is hit - route.dependant.scope = "lifespan" - result = _collect_lifespan_dependants(router) - assert len(result) == 1 - assert result[0].computed_scope == "lifespan" + assert events == [] + with TestClient(app) as client: + assert events == ["start"] + r1 = client.get("/") + r2 = client.get("/") + assert r1.status_code == 200 + assert r2.status_code == 200 + assert events == ["start"] + assert events == ["start", "stop"] def test_lifespan_dependency_synthetic_request_receive_send() -> None: From 17f10eb0a8ca6f620964d6d871b18031a7129e8d Mon Sep 17 00:00:00 2001 From: essentiaMarco <131397104+essentiaMarco@users.noreply.github.com> Date: Mon, 16 Mar 2026 02:34:04 -0700 Subject: [PATCH 09/10] test: replace synthetic collector test with lifecycle integration and remove unreachable collector branch Made-with: Cursor --- fastapi/routing.py | 4 ---- tests/test_dependency_lifespan_scope.py | 24 +++++++++++++----------- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/fastapi/routing.py b/fastapi/routing.py index 3cbcaaf78c..53e0ed5b24 100644 --- a/fastapi/routing.py +++ b/fastapi/routing.py @@ -212,10 +212,6 @@ def _collect_lifespan_dependants(router: "APIRouter") -> list[Dependant]: for route in router.routes: if isinstance(route, APIRoute): flat = get_flat_dependant(route.dependant) - if flat.computed_scope == "lifespan": - key = flat.cache_key - if key not in seen: - seen[key] = flat for d in flat.dependencies: if d.computed_scope == "lifespan": key = d.cache_key diff --git a/tests/test_dependency_lifespan_scope.py b/tests/test_dependency_lifespan_scope.py index eee577813f..7986eec2c3 100644 --- a/tests/test_dependency_lifespan_scope.py +++ b/tests/test_dependency_lifespan_scope.py @@ -4,7 +4,7 @@ from contextlib import asynccontextmanager from typing import Annotated import pytest -from fastapi import Depends, FastAPI +from fastapi import APIRouter, Depends, FastAPI from fastapi.exceptions import DependencyScopeError from fastapi.testclient import TestClient from starlette.requests import Request @@ -104,13 +104,14 @@ def test_lifespan_dependency_same_instance_across_requests() -> None: def test_lifespan_dependency_decorator_level_dependencies_runs_at_startup() -> None: - """Lifespan deps declared in decorator-level dependencies are initialized once at startup.""" - events: list[str] = [] + """Decorator-level dependencies=[Depends(..., scope='lifespan')] run at startup once.""" + started: list[str] = [] + stopped: list[str] = [] def lifespan_dep() -> str: - events.append("start") + started.append("lifespan_dep") yield "ok" - events.append("stop") + stopped.append("lifespan_dep") app = FastAPI() @@ -118,15 +119,16 @@ def test_lifespan_dependency_decorator_level_dependencies_runs_at_startup() -> N def root() -> dict[str, str]: return {"ok": "yes"} - assert events == [] with TestClient(app) as client: - assert events == ["start"] + assert started == ["lifespan_dep"] r1 = client.get("/") r2 = client.get("/") - assert r1.status_code == 200 - assert r2.status_code == 200 - assert events == ["start"] - assert events == ["start", "stop"] + assert r1.status_code == 200 and r2.status_code == 200 + assert r1.json() == {"ok": "yes"} + assert r2.json() == {"ok": "yes"} + assert started == ["lifespan_dep"] + + assert stopped == ["lifespan_dep"] def test_lifespan_dependency_synthetic_request_receive_send() -> None: From 6215c768f2d029c9440dbfe12bc716468a143d5d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 09:35:11 +0000 Subject: [PATCH 10/10] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_dependency_lifespan_scope.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_dependency_lifespan_scope.py b/tests/test_dependency_lifespan_scope.py index 7986eec2c3..b9f1248f94 100644 --- a/tests/test_dependency_lifespan_scope.py +++ b/tests/test_dependency_lifespan_scope.py @@ -4,7 +4,7 @@ from contextlib import asynccontextmanager from typing import Annotated import pytest -from fastapi import APIRouter, Depends, FastAPI +from fastapi import Depends, FastAPI from fastapi.exceptions import DependencyScopeError from fastapi.testclient import TestClient from starlette.requests import Request