Starlette¶
svcs’s Starlette integration stores the svcs.Registry
on the lifespan state and the svcs.Container
is added to the request state using a pure ASGI middleware.
It’s a great way to get type-safety and rich life cycle management on top of Starlette’s low-level dependency capabilities.
Initialization¶
To use svcs with Starlette, you have to pass a lifespan – that has been wrapped by svcs.starlette.lifespan
– and a SVCSMiddleware
to your application:
from starlette.applications import Starlette
from starlette.middleware import Middleware
import svcs
@svcs.starlette.lifespan
async def lifespan(app: Starlette, registry: svcs.Registry):
registry.register_factory(Database, Database.connect)
yield {"your": "other stuff"}
# Registry is closed automatically when the app is done.
app = Starlette(
lifespan=lifespan,
middleware=[Middleware(svcs.starlette.SVCSMiddleware)],
routes=[...],
)
Service Acquisition¶
You can either use svcs.starlette.svcs_from()
:
from svcs.starlette import svcs_from
async def view(request):
db = await svcs_from(request).aget(Database)
Or you can use svcs.starlette.aget()
to extract your services directly:
import svcs
async def view(request):
db = await svcs.starlette.aget(request, Database)
Health Checks¶
As with services, you have the option to either svcs.starlette.svcs_from()
on the request or go straight for svcs.starlette.get_pings()
.
A health endpoint could look like this:
from __future__ import annotations
from starlette.requests import Request
from starlette.responses import JSONResponse
import svcs
async def healthy(request: Request) -> JSONResponse:
"""
Ping all external services.
"""
ok: list[str] = []
failing: list[dict[str, str]] = []
code = 200
for svc in svcs.starlette.get_pings(request):
try:
await svc.aping()
ok.append(svc.name)
except Exception as e:
failing.append({svc.name: repr(e)})
code = 500
return JSONResponse(
content={"ok": ok, "failing": failing}, status_code=code
)
Testing¶
The centralized service registry makes it straight-forward to selectively replace dependencies within your application in tests even if you have many dependencies to handle.
Let’s take this simple application as an example:
from __future__ import annotations
import os
from typing import AsyncGenerator
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.routing import Route
import svcs
config = {"db_url": os.environ.get("DB_URL", "sqlite:///:memory:")}
class Database:
@classmethod
async def connect(cls, db_url: str) -> Database:
# ...
return Database()
async def get_user(self, user_id: int) -> dict[str, str]:
return {} # not interesting here
async def get_user(request: Request) -> JSONResponse:
db = await svcs.starlette.aget(request, Database)
try:
return JSONResponse(
{"data": await db.get_user(request.path_params["user_id"])}
)
except Exception as e:
return JSONResponse({"oh no": e.args[0]})
@svcs.starlette.lifespan
async def lifespan(
app: Starlette, registry: svcs.Registry
) -> AsyncGenerator[dict[str, object], None]:
async def connect_to_db() -> Database:
return await Database.connect(config["db_url"])
registry.register_factory(Database, connect_to_db)
yield {"your": "other stuff"}
app = Starlette(
lifespan=lifespan,
middleware=[Middleware(svcs.starlette.SVCSMiddleware)],
routes=[Route("/users/{user_id}", get_user)],
)
Now if you want to make a request against the get_user
view, but want the database to raise an error to see if it’s properly handled, you can do this:
from unittest.mock import Mock
import pytest
from starlette.testclient import TestClient
from simple_starlette_app import Database, app, lifespan
@pytest.fixture(name="client")
def _client():
with TestClient(app) as client:
yield client
def test_db_goes_boom(client):
"""
Database errors are handled gracefully.
"""
# IMPORTANT: Overwriting must happen AFTER the app is ready!
db = Mock(spec_set=Database)
db.get_user.side_effect = Exception("boom")
lifespan.registry.register_value(Database, db)
resp = client.get("/users/42")
assert {"oh no": "boom"} == resp.json()
As you can see, we can inspect the decorated lifespan function to get the registry that got injected and you can overwrite it later.
Important
You must overwrite after the application has been initialized. Otherwise the lifespan function overwrites your settings.
Cleanup¶
If you initialize the application with a lifespan and middleware as shown above, and use svcs_from()
or aget()
to get your services, everything is cleaned up behind you automatically.
API Reference¶
Application Life Cycle¶
- class svcs.starlette.lifespan(lifespan)[source]¶
Make a Starlette lifespan svcs-aware.
Makes sure that the registry is available to the decorated lifespan function as a second parameter and that the registry is closed when the application exists.
Async generators are automatically wrapped into an async context manager.
- Parameters:
lifespan (Callable[[Starlette, svcs.Registry], contextlib.AbstractAsyncContextManager[dict[str, object]]] | Callable[[Starlette, svcs.Registry], contextlib.AbstractAsyncContextManager[None]] | Callable[[Starlette, svcs.Registry], AsyncGenerator[dict[str, object], None]] | Callable[[Starlette, svcs.Registry], AsyncGenerator[None, None]]) – The lifespan function to make svcs-aware.
- class svcs.starlette.SVCSMiddleware(app)[source]¶
Attach a
svcs.Container
to the request state, based on a registry that has been put on the request state bylifespan
. Closes the container at the end of a request or websocket connection.
See also
Service Acquisition¶
- async svcs.starlette.aget(request: starlette.requests.Request, svc_type1: type, ...)[source]¶
Same as
svcs.Container.aget()
, but uses the container from request.
- async svcs.starlette.aget_abstract(request, *svc_types)[source]¶
Same as
svcs.Container.aget_abstract()
, but uses container from request.
- svcs.starlette.get_pings(request)[source]¶
Same as
svcs.Container.get_pings()
, but uses the container from request.See also