Lazily initialized ASGI apps.
Web servers like Gunicorn and Uvicorn preferĀ¹ to have applications as an attribute on a module, something like:
from fastapi import FastAPI
app = FastAPI()
- Uvicorn can be run programmatically as long as you are not running Gunicorn on top of it. To the best of my knowledge Gunicorn can only be run from the command line.
There are entire patterns built around this, like the @app.<method>
pattern that FastAPI and Flask use.
The problem with this pattern, especially for ASGI apps, is that resources like database connections, http clients and even TaskGroups require an async context to be initialized.
The solution to this was ASGI lifespans, which are part of the ASGI spec. This solution works great for simple cases but doesn't completely solve the issue for larger applications, primarily because it doesn't provide a good place to store state. There's several workarounds frameworks use for this, including:
- Storing data on the
app
instance. Starlette and Quart do this. This generally works of course, but is not without it's downsides. - Dependency injection. FastAPI and Xpresso (I am the author of the latter) propose using dependency injection. In the case of FastAPI you still need to store things on the
app
instance but at least you can move the type casting outside of your endpoint function. Xpresso provides storage for lifepsan-scoped dependencies, but it can be boilerplatey and error prone. There are also many valid objections to using a dependency injection container in and of itself.
The ideal solution to all of this would be if the servers supported app
being a Callable[[], AsyncContextManager[ASGIApp]]
but sadly no server supports this.
This repo tries to provide a solution to this problem that is the next best/closest thing: lazily initialized apps.
LazyApp
is simply a valid ASGI app that can live as a module global.
When it's lifespan is triggered, it calls a user defined async context manager that initializes the ASGI app and any resources it depends on and also calls that ASGI app's lifespan.
This example employs so called "pure" dependency injection to initialize a Starlette application with a database dependency.
from contextlib import asynccontextmanager
from functools import partial
from typing import AsyncIterator
from lazgi import LazyApp
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import Response
from starlette.routing import Route
# provided by the database driver
class DBConnection:
async def execute(self, query: str) -> None:
print(query)
@asynccontextmanager
async def connect() -> AsyncIterator[DBConnection]:
yield DBConnection()
# user code, maybe in some endpoints.py file
async def endpoint(request: Request, db: DBConnection) -> Response:
await db.execute("SELECT 1!")
return Response()
# user code, in main.py or app.py
# note how create_app _explicitly_ lists all of the dependencies
# with their appropriate types
def create_app(db: DBConnection) -> Starlette:
return Starlette(
routes=[Route("/", partial(endpoint, db=db))]
)
# user code, probably in main.py
# the composition root (dependency injection term)
# where we create all dependencies and "bind them"
# (in this case that just means passing them into create_app)
@asynccontextmanager
async def main() -> AsyncIterator[Starlette]:
async with connect() as db:
yield create_app(db)
# a global object that can be accessed
# by Guncicorn or Uvicorn as main:app
app = LazyApp(main)
# an example test
# you'd probably use a pytest fixture to create
# and tear down the db and such
async def test_app() -> None:
async with connect() as db:
app = create_app(db)
# run some tests
# maybe using Starlette's TestClient
# client = TestClient(app)