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
This commit is contained in:
essentiaMarco 2026-03-15 16:57:47 -07:00
parent 9c380fe3d9
commit df44ff0d9e
5 changed files with 91 additions and 5 deletions

View File

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

View File

@ -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]:

View File

@ -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
):

View File

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

View File

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