Repository: tiangolo/fastapi

Issue:
Support starlette "lifespan" context for application
### First check

* [x] I added a very descriptive title to this issue.
* [x] I used the GitHub search to find a similar issue and didn't find it.
* [x] I searched the FastAPI documentation, with the integrated search.
* [x] I already searched in Google "How to X in FastAPI" and didn't find any information.
* [x] I already read and followed all the tutorial in the docs and didn't find an answer.
* [x] I already checked if it is not related to FastAPI but to [Pydantic](https://github.com/samuelcolvin/pydantic).
* [x] I already checked if it is not related to FastAPI but to [Swagger UI](https://github.com/swagger-api/swagger-ui).
* [x] I already checked if it is not related to FastAPI but to [ReDoc](https://github.com/Redocly/redoc).
* [x] After submitting this, I commit to:
    * Read open issues with questions until I find 2 issues where I can help someone and add a comment to help there.
    * Or, I already hit the "watch" button in this repository to receive notifications and I commit to help at least 2 people that ask questions in the future.
    * Implement a Pull Request for a confirmed bug.

<!-- 

I'm asking all this because answering questions and solving problems in GitHub issues consumes a lot of time. I end up not being able to add new features, fix bugs, review Pull Requests, etc. as fast as I wish because I have to spend too much time handling issues.

All that, on top of all the incredible help provided by a bunch of community members that give a lot of their time to come here and help others.

That's a lot of work they are doing, but if more FastAPI users came to help others like them just a little bit more, it would be much less effort for them (and you and me 😅).

-->

### Description

Starlette added support for the lifespan context function in 0.13.5.  It's not well documented, but the code does [suggest](https://github.com/encode/starlette/blob/ed73b97c9b8f4aa527eed88032c04ce9fe109a3c/starlette/applications.py#L51):
```python
        # The lifespan context function is a newer style that replaces
        # on_startup / on_shutdown handlers. Use one or the other, not both.
```

For my purposes, it's much more convenient to use a (async)contextmanager for the startup and shutdown events of my application.  It would be nice to have the option

### The solution you would like

I'd like an easier way, and one that's officially supported, to use the lifespan context function.

```Python
from fastapi import FastAPI

async def lifespan(app):
    print("startup")
    async with SomeResource():
        yield
    print("shutdown")

app = FastAPI(lifespan=lifespan)
```

or

```Python
from fastapi import FastAPI

app = FastAPI()

@app.lifespan
async def lifespan(app):
    print("startup")
    async with SomeResource():
        yield
    print("shutdown")
```

### Describe alternatives you've considered

I can already accomplish this simply by doing:
```python
from fastapi import FastAPI

async def lifespan(app):
    print("startup")
    async with SomeResource():
        yield
    print("shutdown")

app = FastAPI()
app.router.lifespan_context = lifespan
```
however this is not officially supported and would likely break if accidentally using `app.on_event` in addition.

One could also do nasty stuff with `__aenter__` and `__aexit__`:
```python
from fastapi import FastAPI

app = FastAPI()

@app.on_event("startup")
async def startup()
    print("startup")
    app.state.resource = SomeResource()
    await app.state.resource.__aenter__()

@app.on_event("shutdown")
async def startup()
    print("shutdown")
    await app.state.resource.__aexit__(None, None, None)
```
but that seems quite ugly to me.

### Environment

* OS: Linux
* FastAPI Version: 0.63.0
* Python version: 3.8.5


Discussion:
@uSpike Hi! Did you look into [asgi-lifespan](https://pypi.org/project/asgi-lifespan/) package?
@juntatalor I did not look in to asgi-lifespan.  It seems strange to use something outside of starlette, when starlette already supports the lifespan context:

```python
from starlette import Starlette

async def lifespan(app):
    async with SomeResource():
        yield

app = Starlette(lifespan=lifespan)
```

What I'm looking for in this request is to have the starlette functionality exposed in fastapi.

Resolving patch:
diff --git a/docs_src/events/tutorial003.py b/docs_src/events/tutorial003.py
new file mode 100644
--- /dev/null
+++ b/docs_src/events/tutorial003.py
@@ -0,0 +1,28 @@
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI
+
+
+def fake_answer_to_everything_ml_model(x: float):
+    return x * 42
+
+
+ml_models = {}
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+    # Load the ML model
+    ml_models["answer_to_everything"] = fake_answer_to_everything_ml_model
+    yield
+    # Clean up the ML models and release the resources
+    ml_models.clear()
+
+
+app = FastAPI(lifespan=lifespan)
+
+
+@app.get("/predict")
+async def predict(x: float):
+    result = ml_models["answer_to_everything"](x)
+    return {"result": result}
diff --git a/fastapi/applications.py b/fastapi/applications.py
--- a/fastapi/applications.py
+++ b/fastapi/applications.py
@@ -1,6 +1,7 @@
 from enum import Enum
 from typing import (
     Any,
+    AsyncContextManager,
     Awaitable,
     Callable,
     Coroutine,
@@ -71,6 +72,7 @@ def __init__(
         ] = None,
         on_startup: Optional[Sequence[Callable[[], Any]]] = None,
         on_shutdown: Optional[Sequence[Callable[[], Any]]] = None,
+        lifespan: Optional[Callable[["FastAPI"], AsyncContextManager[Any]]] = None,
         terms_of_service: Optional[str] = None,
         contact: Optional[Dict[str, Union[str, Any]]] = None,
         license_info: Optional[Dict[str, Union[str, Any]]] = None,
@@ -125,6 +127,7 @@ def __init__(
             dependency_overrides_provider=self,
             on_startup=on_startup,
             on_shutdown=on_shutdown,
+            lifespan=lifespan,
             default_response_class=default_response_class,
             dependencies=dependencies,
             callbacks=callbacks,
diff --git a/fastapi/routing.py b/fastapi/routing.py
--- a/fastapi/routing.py
+++ b/fastapi/routing.py
@@ -7,6 +7,7 @@
 from enum import Enum, IntEnum
 from typing import (
     Any,
+    AsyncContextManager,
     Callable,
     Coroutine,
     Dict,
@@ -492,6 +493,7 @@ def __init__(
         route_class: Type[APIRoute] = APIRoute,
         on_startup: Optional[Sequence[Callable[[], Any]]] = None,
         on_shutdown: Optional[Sequence[Callable[[], Any]]] = None,
+        lifespan: Optional[Callable[[Any], AsyncContextManager[Any]]] = None,
         deprecated: Optional[bool] = None,
         include_in_schema: bool = True,
         generate_unique_id_function: Callable[[APIRoute], str] = Default(
@@ -504,6 +506,7 @@ def __init__(
             default=default,
             on_startup=on_startup,
             on_shutdown=on_shutdown,
+            lifespan=lifespan,
         )
         if prefix:
             assert prefix.startswith("/"), "A path prefix must start with '/'"


Test patch:
diff --git a/tests/test_router_events.py b/tests/test_router_events.py
--- a/tests/test_router_events.py
+++ b/tests/test_router_events.py
@@ -1,3 +1,7 @@
+from contextlib import asynccontextmanager
+from typing import AsyncGenerator, Dict
+
+import pytest
 from fastapi import APIRouter, FastAPI
 from fastapi.testclient import TestClient
 from pydantic import BaseModel
@@ -12,57 +16,49 @@ class State(BaseModel):
     sub_router_shutdown: bool = False
 
 
-state = State()
-
-app = FastAPI()
-
-
-@app.on_event("startup")
-def app_startup():
-    state.app_startup = True
-
-
-@app.on_event("shutdown")
-def app_shutdown():
-    state.app_shutdown = True
-
+@pytest.fixture
+def state() -> State:
+    return State()
 
-router = APIRouter()
 
+def test_router_events(state: State) -> None:
+    app = FastAPI()
 
-@router.on_event("startup")
-def router_startup():
-    state.router_startup = True
+    @app.get("/")
+    def main() -> Dict[str, str]:
+        return {"message": "Hello World"}
 
+    @app.on_event("startup")
+    def app_startup() -> None:
+        state.app_startup = True
 
-@router.on_event("shutdown")
-def router_shutdown():
-    state.router_shutdown = True
+    @app.on_event("shutdown")
+    def app_shutdown() -> None:
+        state.app_shutdown = True
 
+    router = APIRouter()
 
-sub_router = APIRouter()
+    @router.on_event("startup")
+    def router_startup() -> None:
+        state.router_startup = True
 
+    @router.on_event("shutdown")
+    def router_shutdown() -> None:
+        state.router_shutdown = True
 
-@sub_router.on_event("startup")
-def sub_router_startup():
-    state.sub_router_startup = True
+    sub_router = APIRouter()
 
+    @sub_router.on_event("startup")
+    def sub_router_startup() -> None:
+        state.sub_router_startup = True
 
-@sub_router.on_event("shutdown")
-def sub_router_shutdown():
-    state.sub_router_shutdown = True
+    @sub_router.on_event("shutdown")
+    def sub_router_shutdown() -> None:
+        state.sub_router_shutdown = True
 
+    router.include_router(sub_router)
+    app.include_router(router)
 
-@sub_router.get("/")
-def main():
-    return {"message": "Hello World"}
-
-
-router.include_router(sub_router)
-app.include_router(router)
-
-
-def test_router_events():
     assert state.app_startup is False
     assert state.router_startup is False
     assert state.sub_router_startup is False
@@ -85,3 +81,28 @@ def test_router_events():
     assert state.app_shutdown is True
     assert state.router_shutdown is True
     assert state.sub_router_shutdown is True
+
+
+def test_app_lifespan_state(state: State) -> None:
+    @asynccontextmanager
+    async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
+        state.app_startup = True
+        yield
+        state.app_shutdown = True
+
+    app = FastAPI(lifespan=lifespan)
+
+    @app.get("/")
+    def main() -> Dict[str, str]:
+        return {"message": "Hello World"}
+
+    assert state.app_startup is False
+    assert state.app_shutdown is False
+    with TestClient(app) as client:
+        assert state.app_startup is True
+        assert state.app_shutdown is False
+        response = client.get("/")
+        assert response.status_code == 200, response.text
+        assert response.json() == {"message": "Hello World"}
+    assert state.app_startup is True
+    assert state.app_shutdown is True
diff --git a/tests/test_tutorial/test_events/test_tutorial003.py b/tests/test_tutorial/test_events/test_tutorial003.py
new file mode 100644
--- /dev/null
+++ b/tests/test_tutorial/test_events/test_tutorial003.py
@@ -0,0 +1,86 @@
+from fastapi.testclient import TestClient
+
+from docs_src.events.tutorial003 import (
+    app,
+    fake_answer_to_everything_ml_model,
+    ml_models,
+)
+
+openapi_schema = {
+    "openapi": "3.0.2",
+    "info": {"title": "FastAPI", "version": "0.1.0"},
+    "paths": {
+        "/predict": {
+            "get": {
+                "summary": "Predict",
+                "operationId": "predict_predict_get",
+                "parameters": [
+                    {
+                        "required": True,
+                        "schema": {"title": "X", "type": "number"},
+                        "name": "x",
+                        "in": "query",
+                    }
+                ],
+                "responses": {
+                    "200": {
+                        "description": "Successful Response",
+                        "content": {"application/json": {"schema": {}}},
+                    },
+                    "422": {
+                        "description": "Validation Error",
+                        "content": {
+                            "application/json": {
+                                "schema": {
+                                    "$ref": "#/components/schemas/HTTPValidationError"
+                                }
+                            }
+                        },
+                    },
+                },
+            }
+        }
+    },
+    "components": {
+        "schemas": {
+            "HTTPValidationError": {
+                "title": "HTTPValidationError",
+                "type": "object",
+                "properties": {
+                    "detail": {
+                        "title": "Detail",
+                        "type": "array",
+                        "items": {"$ref": "#/components/schemas/ValidationError"},
+                    }
+                },
+            },
+            "ValidationError": {
+                "title": "ValidationError",
+                "required": ["loc", "msg", "type"],
+                "type": "object",
+                "properties": {
+                    "loc": {
+                        "title": "Location",
+                        "type": "array",
+                        "items": {"anyOf": [{"type": "string"}, {"type": "integer"}]},
+                    },
+                    "msg": {"title": "Message", "type": "string"},
+                    "type": {"title": "Error Type", "type": "string"},
+                },
+            },
+        }
+    },
+}
+
+
+def test_events():
+    assert not ml_models, "ml_models should be empty"
+    with TestClient(app) as client:
+        assert ml_models["answer_to_everything"] == fake_answer_to_everything_ml_model
+        response = client.get("/openapi.json")
+        assert response.status_code == 200, response.text
+        assert response.json() == openapi_schema
+        response = client.get("/predict", params={"x": 2})
+        assert response.status_code == 200, response.text
+        assert response.json() == {"result": 84.0}
+    assert not ml_models, "ml_models should be empty"