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] 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)