Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 62 additions & 1 deletion fastapi/applications.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import inspect
from collections.abc import Awaitable, Callable, Coroutine, Sequence
from contextlib import AsyncExitStack, asynccontextmanager
from enum import Enum
from typing import Annotated, Any, TypeVar

Expand Down Expand Up @@ -37,6 +39,50 @@

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)
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:
if fastapi_app is not None:
stack = AsyncExitStack()
await stack.__aenter__()
cache: dict[Any, Any] = {}
await routing._run_lifespan_dependencies(
router_for_deps, 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):
"""
Expand Down Expand Up @@ -979,13 +1025,27 @@ class Item(BaseModel):
"""
),
] = {}
_inner_lifespan: Callable[[Any], Any]
if lifespan is None:

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):
_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,
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,
Expand All @@ -995,6 +1055,7 @@ class Item(BaseModel):
generate_unique_id_function=generate_unique_id_function,
strict_content_type=strict_content_type,
)
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)
Expand Down
7 changes: 5 additions & 2 deletions fastapi/cli.py
Original file line number Diff line number Diff line change
@@ -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

except ImportError: # pragma: no cover
cli_main = None # type: ignore
pass


def main() -> None:
if not cli_main: # type: ignore[truthy-function] # ty: ignore[unused-ignore-comment]
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
Expand Down
2 changes: 1 addition & 1 deletion fastapi/dependencies/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
46 changes: 44 additions & 2 deletions fastapi/dependencies/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -327,6 +327,22 @@ 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
call_name = getattr(dependant.call, "__name__", "<unnamed_callable>")
raise DependencyScopeError(
f'The dependency "{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:
Expand Down Expand Up @@ -608,6 +624,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), (
Expand Down Expand Up @@ -656,13 +673,38 @@ 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:
errors.extend(solved_result.errors)
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] # pragma: no cover
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
):
Expand Down
6 changes: 5 additions & 1 deletion fastapi/param_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion fastapi/params.py
Original file line number Diff line number Diff line change
Expand Up @@ -746,7 +746,7 @@ def __init__(
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)
Expand Down
65 changes: 65 additions & 0 deletions fastapi/routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,61 @@ def wrapper(app: Any) -> _AsyncLiftContextManager[Any]:
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)
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

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: # pragma: no cover
pass # ASGI send not used by lifespan dependency resolution

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]:
Expand Down Expand Up @@ -454,11 +509,16 @@ async def app(request: Request) -> Response:
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=cast(dict[str, Any] | FormData | bytes | None, body),
dependency_overrides_provider=dependency_overrides_provider,
dependency_cache=dependency_cache,
async_exit_stack=async_exit_stack,
embed_body_fields=embed_body_fields,
)
Expand Down Expand Up @@ -748,10 +808,15 @@ async def app(websocket: WebSocket) -> None:
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,
)
Expand Down
Loading
Loading