Skip to content

Commit

Permalink
Merge pull request #217 from lsst-sqre/tickets/DM-41630
Browse files Browse the repository at this point in the history
DM-41630: Convert to FastAPI lifespan functions
  • Loading branch information
rra authored Nov 15, 2023
2 parents e144653 + 451f98c commit 7e1ac3a
Show file tree
Hide file tree
Showing 9 changed files with 83 additions and 42 deletions.
15 changes: 10 additions & 5 deletions docs/user-guide/arq.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,26 @@ Quick start
Dependency set up and configuration
-----------------------------------

In your application's FastAPI setup module, typically :file:`main.py`, you need to initialize `safir.dependencies.arq.ArqDependency` during the start up event:
In your application's FastAPI setup module, typically :file:`main.py`, you need to initialize `safir.dependencies.arq.ArqDependency` during your lifespan function.

.. code-block:: python
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI
from safir.dependencies.arq import arq_dependency
app = FastAPI()
@app.on_event("startup")
async def startup() -> None:
@asynccontextmanager
def lifespan(app: FastAPI) -> AsyncIterator[None]:
await arq_dependency.initialize(
mode=config.arq_mode, redis_settings=config.arq_redis_settings
)
yield
app = FastAPI(lifespan=lifespan)
The ``mode`` parameter for `safir.dependencies.arq.ArqDependency.initialize` takes `ArqMode` enum values of either ``"production"`` or ``"test"``. The ``"production"`` mode configures a real arq_ queue backed by Redis, whereas ``"test"`` configures a mock version of the arq_ queue.

Expand Down
22 changes: 12 additions & 10 deletions docs/user-guide/database.rst
Original file line number Diff line number Diff line change
Expand Up @@ -144,30 +144,32 @@ For FastAPI applications, Safir provides a FastAPI dependency that creates a dat
This uses the `SQLAlchemy async_scoped_session <https://docs.sqlalchemy.org/en/14/orm/extensions/asyncio.html#using-asyncio-scoped-session>`__ to transparently manage a separate session per running task.

To use the database session dependency, it must first be initialized during application startup.
Generally this is done inside the application startup event:
Generally this is done inside the application lifespan function.
You must also close the dependency during application shutdown.

.. code-block:: python
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from safir.dependencies.db_session import db_session_dependency
from .config import config
@app.on_event("startup")
async def startup_event() -> None:
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
await db_session_dependency.initialize(
config.database_url, config.database_password
)
yield
await db_session_dependency.aclose()
As with some of the examples above, this assumes the application has a ``config`` object with the application settings, including the database URL and password.

You must also close the dependency during application shutdown:
.. code-block:: python
app = FastAPI(lifespan=lifespan)
@app.on_event("shutdown")
async def shutdown_event() -> None:
await db_session_dependency.aclose()
As with some of the examples above, this assumes the application has a ``config`` object with the application settings, including the database URL and password.

Then, any handler that needs a database session can depend on the `~safir.dependencies.db_session.db_session_dependency`:

Expand Down
16 changes: 10 additions & 6 deletions docs/user-guide/http-client.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,24 @@ Setting up the httpx.AsyncClient
The ``httpx.AsyncClient`` will be dyanmically created during application startup.
Nothing further is needed apart from importing the dependency.
However, it must be closed during application shutdown or a warning will be generated.

To do this, add a shutdown hook to your application:
This is normally done during the lifespan function for the FastAPI app.

.. code-block:: python
from safir.dependencies.http_client import http_client_dependency
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
app = FastAPI()
from safir.dependencies.http_client import http_client_dependency
@app.on_event("shutdown")
async def shutdown_event() -> None:
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
yield
await http_client_dependency.aclose()
app = FastAPI(lifespan=lifespan)
You can add this line to an existing shutdown hook if you already have one.

Using the httpx.AsyncClient
Expand Down
9 changes: 7 additions & 2 deletions docs/user-guide/kubernetes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,17 @@ For example:

.. code-block:: python
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from safir.kubernetes import initialize_kubernetes
@app.on_event("startup")
async def startup_event() -> None:
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
await initialize_kubernetes()
yield
Testing with mock Kubernetes
============================
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ db = [
dev = [
"asgi-lifespan",
"coverage[toml]",
"fastapi>=0.93.0",
"flake8",
"mypy",
"pre-commit",
Expand Down
15 changes: 10 additions & 5 deletions src/safir/dependencies/arq.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,22 +43,27 @@ async def initialize(
--------
.. code-block:: python
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI
from safir.arq import ArqMode, ArqQueue
from safir.dependencies.arq import arq_dependency
app = FastAPI()
@app.on_event("startup")
async def startup() -> None:
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
await arq_dependency.initialize(mode=ArqMode.test)
yield
app = FastAPI()
@app.post("/")
async def post_job(
arq_queue: ArqQueue = Depends(arq_dependency),
) -> Dict[str, Any]:
) -> dict[str, Any]:
job = await arq_queue.enqueue("test_task", "hello", an_int=42)
return {"job_id": job.id}
"""
Expand Down
18 changes: 14 additions & 4 deletions src/safir/dependencies/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,24 @@ class HTTPClientDependency:
Notes
-----
The application must call ``http_client_dependency.aclose()`` as part of a
shutdown hook:
The application must call ``http_client_dependency.aclose()`` in the
application lifespan hook:
.. code-block:: python
@app.on_event("shutdown")
async def shutdown_event() -> None:
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
yield
await http_client_dependency.aclose()
app = FastAPI(lifespan=lifespan)
"""

def __init__(self) -> None:
Expand Down
14 changes: 9 additions & 5 deletions tests/dependencies/arq_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

from __future__ import annotations

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any

import pytest
Expand All @@ -14,10 +16,16 @@
from safir.dependencies.arq import arq_dependency


@asynccontextmanager
async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
await arq_dependency.initialize(mode=ArqMode.test, redis_settings=None)
yield


@pytest.mark.asyncio
async def test_arq_dependency_mock() -> None:
"""Test the arq dependency entirely through the MockArqQueue."""
app = FastAPI()
app = FastAPI(lifespan=_lifespan)

@app.post("/")
async def post_job(
Expand Down Expand Up @@ -109,10 +117,6 @@ async def post_job_complete(
except JobNotFound as e:
raise HTTPException(status_code=404, detail=str(e)) from e

@app.on_event("startup")
async def startup() -> None:
await arq_dependency.initialize(mode=ArqMode.test, redis_settings=None)

async with LifespanManager(app):
async with AsyncClient(app=app, base_url="http://example.com") as c:
r = await c.post("/")
Expand Down
15 changes: 10 additions & 5 deletions tests/dependencies/http_client_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

from __future__ import annotations

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

import pytest
import respx
from asgi_lifespan import LifespanManager
Expand All @@ -16,9 +19,15 @@ def non_mocked_hosts() -> list[str]:
return ["example.com"]


@asynccontextmanager
async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
yield
await http_client_dependency.aclose()


@pytest.mark.asyncio
async def test_http_client(respx_mock: respx.Router) -> None:
app = FastAPI()
app = FastAPI(lifespan=_lifespan)
respx_mock.get("https://www.google.com").respond(200)

@app.get("/")
Expand All @@ -29,10 +38,6 @@ async def handler(
await http_client.get("https://www.google.com")
return {}

@app.on_event("shutdown")
async def shutdown_event() -> None:
await http_client_dependency.aclose()

async with LifespanManager(app):
async with AsyncClient(app=app, base_url="http://example.com") as c:
r = await c.get("/")
Expand Down

0 comments on commit 7e1ac3a

Please sign in to comment.