Skip to content

Commit

Permalink
Add Starlette integration (#31)
Browse files Browse the repository at this point in the history
  • Loading branch information
hynek authored Aug 17, 2023
1 parent 9b39cdf commit 6615301
Show file tree
Hide file tree
Showing 16 changed files with 764 additions and 5 deletions.
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,13 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/

## [Unreleased](https://github.com/hynek/svcs/compare/23.17.0...HEAD)

###
### Added

- Flask: `svcs.flask.get_registry()`.

- Starlette integration.
[#31](https://github.com/hynek/svcs/pull/31)


## [23.17.0](https://github.com/hynek/svcs/compare/23.16.0...23.17.0) - 2023-08-15

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ def view(request):
To a type checker like [Mypy](https://mypy-lang.org), `db` has the type `Database`, `api` has the type `WebAPIClient`, and `cache` has the type `Cache`.
<!-- end addendum -->

*svcs* comes with seamless integration for **AIOHTTP**, **FastAPI**, **Flask**, **Pyramid**, and first-class **async** support.
*svcs* comes with seamless integration for **AIOHTTP**, **FastAPI**, **Flask**, **Pyramid**, and **Starlette**.

<!-- begin typing -->
While *svcs* also has first-class support for static typing, it is **strictly optional** and will always remain so.
Expand Down
2 changes: 2 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@
("py:class", "aiohttp.web_request.Request"),
# Welcome, MkDocs projects. :(
("py:class", "FastAPI"),
("py:class", "Starlette"),
("py:class", "starlette.requests.Request"),
]

# If true, '()' will be appended to :func: etc. cross-reference text.
Expand Down
4 changes: 4 additions & 0 deletions docs/core-concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,10 @@ Here's how a health check endpoint could look like:
```{literalinclude} examples/flask/health_check.py
```
:::
::: {tab} Starlette
```{literalinclude} examples/starlette/health_check.py
```
:::
<!-- end health checks -->


Expand Down
27 changes: 27 additions & 0 deletions docs/examples/starlette/health_check.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
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
)
56 changes: 56 additions & 0 deletions docs/examples/starlette/simple_starlette_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
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)],
)
28 changes: 28 additions & 0 deletions docs/examples/starlette/test_simple_starlette_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
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()
10 changes: 10 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@ def view(request):
...
```
:::
::: {tab} Starlette
```python
import svcs

async def view(request):
db, api, cache = await svcs.starlette.aget(request, Database, WebAPI, Cache)

...
```
:::
<!-- end tabbed teaser -->

```{include} ../README.md
Expand Down
1 change: 1 addition & 0 deletions docs/integrations/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,6 @@ aiohttp
fastapi
flask
pyramid
starlette
custom
```
118 changes: 118 additions & 0 deletions docs/integrations/starlette.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Starlette

*svcs*'s [Starlette](https://www.starlette.io/) integration stores the {class}`svcs.Registry` on the *lifespan state* and the {class}`svcs.Container` is added to the request state using a [pure ASGI middleware](https://www.starlette.io/middleware/#pure-asgi-middleware).

(starlette-init)=

## Initialization

To use *svcs* with Starlette, you have to pass a [*lifespan*](https://www.starlette.io/lifespan/) -- that has been wrapped by {class}`svcs.starlette.lifespan` -- and a {class}`~svcs.starlette.SVCSMiddleware` to your application:

```python
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=[...],
)
```

(starlette-get)=

## Service Acquisition

You can either use {func}`svcs.starlette.svcs_from`:

```python
from svcs.starlette import svcs_from

async def view(request):
db = await svcs_from(request).aget(Database)
```

Or you can use {func}`svcs.starlette.aget` to extract your services directly:

```python
import svcs

async def view(request):
db = await svcs.starlette.aget(request, Database)
```

(starlette-health)=

## Health Checks

As with services, you have the option to either {func}`svcs.starlette.svcs_from` on the request or go straight for {func}`svcs.starlette.get_pings`.

A health endpoint could look like this:

```{literalinclude} ../examples/starlette/health_check.py
```

## 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:

```{literalinclude} ../examples/starlette/simple_starlette_app.py
```

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:

```{literalinclude} ../examples/starlette/test_simple_starlette_app.py
```

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 {func}`~svcs.starlette.svcs_from` or {func}`~svcs.starlette.aget` to get your services, everything is cleaned up behind you automatically.


## API Reference

### Application Life Cycle

```{eval-rst}
.. module:: svcs.starlette
.. autoclass:: lifespan(lifespan)
.. autoclass:: SVCSMiddleware
.. seealso:: :ref:`fastapi-init`
```


### Service Acquisition

```{eval-rst}
.. function:: aget(request: starlette.requests.Request, svc_type1: type, ...)
:async:
Same as :meth:`svcs.Container.aget`, but uses the container from *request*.
.. autofunction:: aget_abstract
.. autofunction:: svcs_from
.. autofunction:: get_pings
```
9 changes: 8 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ typing = [
"mypy>=1.4",
"fastapi",
"flask",
"starlette",
"aiohttp; python_version<'3.12'",
"pyramid; python_version<'3.12'",
]
Expand All @@ -52,6 +53,7 @@ docs = [
"fastapi",
"flask",
"pyramid",
"starlette",
]
dev = ["svcs[tests,typing]", "tox>4", "httpx"]

Expand Down Expand Up @@ -247,4 +249,9 @@ ignore = [
[tool.ruff.isort]
lines-between-types = 1
lines-after-imports = 2
known-first-party = ["svcs", "tests", "simple_fastapi_app"]
known-first-party = [
"svcs",
"tests",
"simple_fastapi_app",
"simple_starlette_app",
]
5 changes: 5 additions & 0 deletions src/svcs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@
except ImportError:
__all__ += ["pyramid"]

try:
from . import starlette
except ImportError:
__all__ += ["starlette"]


# Make nicer public names.
__locals = locals()
Expand Down
Loading

0 comments on commit 6615301

Please sign in to comment.