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."""