diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f203f9..818f85b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,11 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/ ## [Unreleased](https://github.com/hynek/svcs/compare/23.16.0...HEAD) +### Added + +- FastAPI integration. + [#20](https://github.com/hynek/svcs/pull/30) + ## [23.16.0](https://github.com/hynek/svcs/compare/23.15.0...23.16.0) - 2023-08-14 @@ -38,7 +43,7 @@ You can find our backwards-compatibility policy [here](https://github.com/hynek/ - Cleanups for services are internally context managers now. For your convenience, if you pass an (async) generator function for a factory, the registry will automatically wrap it for you into an (async) context manager. - [#92](https://github.com/hynek/svcs/pull/29) + [#29](https://github.com/hynek/svcs/pull/29) - Pyramid: `svcs.pyramid.get()` now takes a Pyramid request as the first argument. `svcs.pyramid.get_pings()` also doesn't look at thread locals anymore. diff --git a/README.md b/README.md index d889298..f47aec5 100644 --- a/README.md +++ b/README.md @@ -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`. -*svcs* comes with seamless integration for **AIOHTTP**, **Flask**, **Pyramid**, and first-class **async** support. +*svcs* comes with seamless integration for **AIOHTTP**, **FastAPI**, **Flask**, **Pyramid**, and first-class **async** support. While *svcs* also has first-class support for static typing, it is **strictly optional** and will always remain so. diff --git a/conftest.py b/conftest.py index 95f9077..0960f30 100644 --- a/conftest.py +++ b/conftest.py @@ -10,10 +10,11 @@ from sybil import Sybil from sybil.parsers import myst, rest -from tests.ifaces import Service import svcs +from tests.ifaces import Service + markdown_examples = Sybil( parsers=[ diff --git a/docs/conf.py b/docs/conf.py index dbd592b..79f5e83 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -59,7 +59,9 @@ exclude_patterns = ["_build"] nitpick_ignore = [ + ("py:class", "AbstractAsyncContextManager"), ("py:class", "aiohttp.web_request.Request"), + ("py:class", "FastAPI"), ] # If true, '()' will be appended to :func: etc. cross-reference text. diff --git a/docs/examples/fastapi/health_check.py b/docs/examples/fastapi/health_check.py new file mode 100644 index 0000000..c986e8a --- /dev/null +++ b/docs/examples/fastapi/health_check.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from typing import AsyncGenerator + +from fastapi import FastAPI +from fastapi.responses import JSONResponse + +import svcs + + +@svcs.fastapi.lifespan +async def lifespan( + app: FastAPI, registry: svcs.Registry +) -> AsyncGenerator[dict[str, object], None]: + # Register your services here using the *registry* argument. + + yield {"your": "other state"} + + +app = FastAPI(lifespan=lifespan) + +############################################################################## + + +@app.get("/healthy") +async def healthy(services: svcs.fastapi.DepContainer) -> JSONResponse: + """ + Ping all external services. + """ + ok: list[str] = [] + failing: list[dict[str, str]] = [] + code = 200 + + for svc in services.get_pings(): + 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 + ) diff --git a/docs/examples/fastapi/simple_app.py b/docs/examples/fastapi/simple_app.py new file mode 100644 index 0000000..f6df459 --- /dev/null +++ b/docs/examples/fastapi/simple_app.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import os + +from typing import AsyncGenerator + +from fastapi import FastAPI + +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 + + +@svcs.fastapi.lifespan +async def lifespan( + app: FastAPI, 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 = FastAPI(lifespan=lifespan) + + +@app.get("/users/{user_id}") +async def get_user(user_id: int, services: svcs.fastapi.DepContainer) -> dict: + db = await services.aget(Database) + + try: + return {"data": await db.get_user(user_id)} + except Exception as e: + return {"oh no": e.args[0]} diff --git a/docs/examples/fastapi/test_simple_app.py b/docs/examples/fastapi/test_simple_app.py new file mode 100644 index 0000000..a2ee96c --- /dev/null +++ b/docs/examples/fastapi/test_simple_app.py @@ -0,0 +1,28 @@ +from unittest.mock import Mock + +import pytest + +from fastapi.testclient import TestClient + +from simple_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() diff --git a/docs/index.md b/docs/index.md index 846bfeb..ec29988 100644 --- a/docs/index.md +++ b/docs/index.md @@ -33,6 +33,17 @@ async def view(request): ... ``` ::: +::: {tab} FastAPI +```python +import svcs + +@app.get("/") +async def view(services: svcs.fastapi.DepContainer): + db, api, cache = await services.aget(Database, WebAPI, Cache) + + ... +``` +::: ::: {tab} Flask ```python import svcs diff --git a/docs/integrations/fastapi.md b/docs/integrations/fastapi.md new file mode 100644 index 0000000..cd0bd03 --- /dev/null +++ b/docs/integrations/fastapi.md @@ -0,0 +1,121 @@ +# FastAPI + +*svcs*'s *centralization* and *on-demand* capabilities are a great complement to FastAPI's dependency injection system – especially when the annotated decorator approach becomes unwieldy because of too many dependencies. + +*svcs*'s [FastAPI](https://fastapi.tiangolo.com) integration stores the {class}`svcs.Registry` on the *lifespan state* and the {class}`svcs.Container` is a [dependency](https://fastapi.tiangolo.com/tutorial/dependencies/) that can be injected into your views. +That makes very little API necessary from *svcs* itself. + +(fastapi-init)= + +## Initialization + +FastAPI inherited the [`request.state`](https://www.starlette.io/requests/#other-state) attribute from [*starlette*](https://www.starlette.io/) and *svcs* uses it to store the {class}`svcs.Registry` on it. + +To get it there you have to instantiate your FastAPI application with a *lifespan*. +Whatever this lifespan yields, becomes the initial request state via shallow copy. + +To keep track of its registry for later overwriting, *svcs* comes with the {class}`svcs.fastapi.lifespan` decorator that remembers the registry on the lifespan object (see below in testing to see it in action): + +```python +from fastapi import FastAPI + +import svcs + + +@svcs.fastapi.lifespan +async def lifespan(app: FastAPI, registry: svcs.Registry): + registry.register_factory(Database, Database.connect) + + yield {"your": "other", "initial": "state"} + + # Registry is closed automatically when the app is done. + + +app = FastAPI(lifespan=lifespan) +``` + +::: {seealso} +- [Lifespan state](https://www.starlette.io/lifespan/) in *starlette* documentation. +- [Lifespan](https://fastapi.tiangolo.com/advanced/events/) in FastAPI documentation (more verbose, but doesn't mention lifespan state). +::: + +(fastapi-get)= + +## Service Acquisition + +*svcs* comes with the {func}`svcs.fastapi.container` dependency that will inject a request-scoped {class}`svcs.Container` into your views if the application is correctly initialized: + +```python +from typing import Annotated + +from fastapi import Depends + + +@app.get("/") +async def index(services: Annotated[svcs.Container, Depends(svcs.fastapi.container)]): + db = services.get(Database) +``` + +For your convenience, *svcs* comes with the alias {class}`svcs.fastapi.DepContainer` that allows you to use the shorter and even nicer: + +```python +@app.get("/") +async def index(services: svcs.fastapi.DepContainer): + db = services.get(Database) +``` + +(fastapi-health)= + +## Health Checks + +With the help of the {func}`svcs.fastapi.container` dependency you can easily add a health check endpoint to your application without any special API: + +```{literalinclude} ../examples/fastapi/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 FastAPI application as an example: + +```{literalinclude} ../examples/fastapi/simple_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/fastapi/test_simple_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 as shown above, and use the {func}`svcs.fastapi.container` dependency to get your services, everything is cleaned up behind you automatically. + + +## API Reference + +### Application Life Cycle + +```{eval-rst} +.. autoclass:: svcs.fastapi.lifespan(lifespan) + + .. seealso:: :ref:`fastapi-init` +``` + + +### Service Acquisition + +```{eval-rst} +.. autofunction:: svcs.fastapi.container + +.. autoclass:: svcs.fastapi.DepContainer +``` diff --git a/docs/integrations/index.md b/docs/integrations/index.md index b535f2b..8fa91a8 100644 --- a/docs/integrations/index.md +++ b/docs/integrations/index.md @@ -9,6 +9,7 @@ Here's documentation on those we ship and how you can write your own. :maxdepth: 1 aiohttp +fastapi flask pyramid custom diff --git a/docs/why.md b/docs/why.md index c5dc3ff..b0f1e23 100644 --- a/docs/why.md +++ b/docs/why.md @@ -95,6 +95,10 @@ This is how it could look with the shipped integrations: ```{literalinclude} examples/aiohttp/health_check.py ``` ::: +::: {tab} FastAPI +```{literalinclude} examples/fastapi/health_check.py +``` +::: ::: {tab} Flask ```{literalinclude} examples/flask/health_check.py ``` diff --git a/pyproject.toml b/pyproject.toml index cdf05a0..7e92cb8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dependencies = [ tests = ["pytest", "pytest-asyncio", "sybil"] typing = [ "mypy>=1.4", + "fastapi", "flask", "aiohttp; python_version<'3.12'", "pyramid; python_version<'3.12'", @@ -42,7 +43,9 @@ docs = [ "furo", "sybil", "pytest", + "httpx", "aiohttp", + "fastapi", "flask", "pyramid", ] @@ -162,6 +165,10 @@ allow_any_generics = true module = "pyramid.*" ignore_missing_imports = true +[[tool.mypy.overrides]] +module = "pytest.*" +ignore_missing_imports = true + [[tool.mypy.overrides]] module = "tests.*" ignore_errors = true @@ -236,3 +243,4 @@ ignore = [ [tool.ruff.isort] lines-between-types = 1 lines-after-imports = 2 +known-first-party = ["svcs", "tests", "simple_app"] diff --git a/src/svcs/__init__.py b/src/svcs/__init__.py index 36e0066..1e39b87 100644 --- a/src/svcs/__init__.py +++ b/src/svcs/__init__.py @@ -26,6 +26,11 @@ except ImportError: __all__ += ["aiohttp"] +try: + from . import fastapi +except ImportError: + __all__ += ["fastapi"] + try: from . import flask except ImportError: diff --git a/src/svcs/fastapi.py b/src/svcs/fastapi.py new file mode 100644 index 0000000..123436a --- /dev/null +++ b/src/svcs/fastapi.py @@ -0,0 +1,100 @@ +# SPDX-FileCopyrightText: 2023 Hynek Schlawack +# +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +import inspect +import sys + +from contextlib import AbstractAsyncContextManager, asynccontextmanager +from typing import AsyncGenerator, Callable + +import attrs + +from fastapi import Depends, FastAPI, Request + +import svcs + +from svcs._core import _KEY_REGISTRY + + +if sys.version_info < (3, 9): + from typing_extensions import Annotated +else: + from typing import Annotated + + +@attrs.define +class lifespan: # noqa: N801 + """ + Make a FastAPI 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. + + Arguments: + lifespan: The lifespan function to make *svcs*-aware. + """ + + _lifespan: Callable[ + [FastAPI, svcs.Registry], + AbstractAsyncContextManager[dict[str, object]], + ] | Callable[ + [FastAPI, svcs.Registry], + AbstractAsyncContextManager[None], + ] | Callable[ + [FastAPI, svcs.Registry], AsyncGenerator[dict[str, object], None] + ] | Callable[ + [FastAPI, svcs.Registry], AsyncGenerator[None, None] + ] + _state: dict[str, object] = attrs.field(factory=dict) + registry: svcs.Registry = attrs.field(factory=svcs.Registry) + + @asynccontextmanager + async def __call__( + self, app: FastAPI + ) -> AsyncGenerator[dict[str, object], None]: + cm: Callable[[FastAPI, svcs.Registry], AbstractAsyncContextManager] + if inspect.isasyncgenfunction(self._lifespan): + cm = asynccontextmanager(self._lifespan) + else: + cm = self._lifespan # type: ignore[assignment] + + async with self.registry, cm(app, self.registry) as state: + self._state = state or {} + self._state[_KEY_REGISTRY] = self.registry + yield self._state + + +async def container(request: Request) -> AsyncGenerator[svcs.Container, None]: + """ + A FastAPI `dependency + `_ that provides you + with a request-scoped container. + + Yields: + A :class:`svcs.Container` that is cleaned up after the request. + + See also: + :ref:`fastapi-get` + """ + async with svcs.Container(getattr(request.state, _KEY_REGISTRY)) as cont: + yield cont + + +DepContainer = Annotated[svcs.Container, Depends(container)] +""" +An alias for:: + + typing.Annotated[svcs.Container, fastapi.Depends(svcs.fastapi.container)] + +This allows you write your view like:: + + @app.get("/") + async def view(services: svcs.fastapi.DepContainer): + ... +""" diff --git a/tests/integrations/test_fastapi.py b/tests/integrations/test_fastapi.py new file mode 100644 index 0000000..e01a09e --- /dev/null +++ b/tests/integrations/test_fastapi.py @@ -0,0 +1,73 @@ +# SPDX-FileCopyrightText: 2023 Hynek Schlawack +# +# SPDX-License-Identifier: MIT + +import asyncio + +from contextlib import asynccontextmanager + +import pytest + +import svcs + + +try: + from fastapi import FastAPI + from fastapi.testclient import TestClient +except ImportError: + pytest.skip("FastAPI not installed", allow_module_level=True) + + +@pytest.mark.asyncio() +@pytest.mark.parametrize("yield_something", [True, False]) +@pytest.mark.parametrize("cm", [True, False]) +async def test_integration(yield_something, cm): + """ + Acquiring registered services using a FastAPI dependency works. + """ + registry_closed = closed = False + + async def factory(): + await asyncio.sleep(0) + yield 42 + await asyncio.sleep(0) + + nonlocal closed + closed = True + + async def close_registry(): + nonlocal registry_closed + registry_closed = True + + if yield_something: + + async def lifespan(app: FastAPI, registry: svcs.Registry): + registry.register_factory( + int, factory, on_registry_close=close_registry + ) + + yield {"foo": "bar"} + + else: + + async def lifespan(app: FastAPI, registry: svcs.Registry): + registry.register_factory( + int, factory, on_registry_close=close_registry + ) + + yield + + if cm: + lifespan = asynccontextmanager(lifespan) + + app = FastAPI(lifespan=svcs.fastapi.lifespan(lifespan)) + + @app.get("/") + async def view(services: svcs.fastapi.DepContainer): + return {"val": await services.aget(int)} + + with TestClient(app) as client: + assert {"val": 42} == client.get("/").json() + assert closed + + assert registry_closed diff --git a/tests/typing/fastapi.py b/tests/typing/fastapi.py new file mode 100644 index 0000000..220b487 --- /dev/null +++ b/tests/typing/fastapi.py @@ -0,0 +1,73 @@ +# SPDX-FileCopyrightText: 2023 Hynek Schlawack +# +# SPDX-License-Identifier: MIT + +from __future__ import annotations + +import sys + +from contextlib import asynccontextmanager +from typing import AsyncGenerator + +from fastapi import Depends, FastAPI +from fastapi.responses import JSONResponse + +import svcs + + +if sys.version_info < (3, 9): + from typing_extensions import Annotated +else: + from typing import Annotated + + +@svcs.fastapi.lifespan +async def lifespan( + app: FastAPI, registry: svcs.Registry +) -> AsyncGenerator[dict[str, object], None]: + yield {} + + +reg: svcs.Registry = lifespan.registry + + +@svcs.fastapi.lifespan +async def lifespan2( + app: FastAPI, registry: svcs.Registry +) -> AsyncGenerator[None, None]: + yield + + +@svcs.fastapi.lifespan +@asynccontextmanager +async def lifespan3( + app: FastAPI, registry: svcs.Registry +) -> AsyncGenerator[dict[str, object], None]: + yield {} + + +@svcs.fastapi.lifespan +@asynccontextmanager +async def lifespan4( + app: FastAPI, registry: svcs.Registry +) -> AsyncGenerator[None, None]: + yield + + +app = FastAPI(lifespan=lifespan) + + +@app.get("/") +async def view( + services: Annotated[svcs.Container, Depends(svcs.fastapi.container)] +) -> JSONResponse: + x: int = services.get(int) + + return JSONResponse({}, 200) + + +@app.get("/") +async def view2(services: svcs.fastapi.DepContainer) -> JSONResponse: + x: int = services.get(int) + + return JSONResponse({}, 200) diff --git a/tox.ini b/tox.ini index ccc7ba6..74d4804 100644 --- a/tox.ini +++ b/tox.ini @@ -22,12 +22,13 @@ deps = mypy: mypy tests: coverage[toml] optional: aiohttp + optional: fastapi optional: flask optional: httpx optional: pyramid commands = tests: coverage run -m pytest {posargs} - mypy: mypy tests/typing + mypy: mypy tests/typing docs/examples/fastapi/simple_app.py # The next two environments are necessary because AIOHTTP and Pyramid do not # support Python 3.12 yet. @@ -35,13 +36,18 @@ commands = [testenv:py312-tests-optional] deps = coverage[toml] + fastapi flask [testenv:py312-mypy] deps = + fastapi flask -commands = mypy \ - tests/typing/flask.py +commands = + mypy \ + tests/typing/flask.py \ + tests/typing/fastapi.py \ + docs/examples/fastapi/simple_app.py [testenv:coverage-report]