Writing your own backend¶
A backend is anything implementing the
Backend protocol — two methods, no
registration:
send(request)— do the I/O, return the (wrapped)Response.map(result, fn)— applyfninside the wrapper: plain call / await /addCallback. This is how generic code (likeAPIClient.send) attaches response parsing without knowing your execution model.
In practice, subclass the matching base class and implement only the raw
I/O — hooks, error translation and map come for free:
import urllib.error
import urllib.request
from action0.client import BaseSyncBackend
from action0.client import TransportError
from action0.req import Request
from action0.req import Response
class UrllibBackend(BaseSyncBackend):
"""A tiny stdlib-only backend."""
def _send(self, request: Request) -> Response:
raw = urllib.request.Request(
request.url.as_str(),
method=request.method,
headers=dict(request.headers.items()),
data=request.body_bytes(),
)
with urllib.request.urlopen(raw) as answer:
return Response(
answer.status,
headers=answer.getheaders(),
body=answer.read(),
request=request,
)
def translate_error(self, error: Exception, request: Request) -> BaseException:
if isinstance(error, urllib.error.URLError):
return TransportError(str(error.reason), request=request)
return error
Client(UrllibBackend()) and APIClient(UrllibBackend(), ...) now work,
fully typed, hooks included. The async and Deferred base classes
(BaseAsyncBackend,
BaseDeferredBackend) mirror this with
an async def _send / a Deferred-returning _send.
Other execution models¶
The protocol is generic over the wrapper type, so backends are not limited to the shipped execution models (sync, awaitable, Deferred, Future). Say your framework has a result wrapper of its own:
from typing import Callable
from typing import Generic
from typing import TypeVar
from action0.client import Client
from action0.req import Request
from action0.req import Response
T = TypeVar("T")
S = TypeVar("S")
class Box(Generic[T]):
"""Stand-in for your framework's own result wrapper."""
class BoxBackend:
def send(self, request: Request) -> Box[Response]: ...
def map(self, result: Box[T], fn: Callable[[T], S]) -> Box[S]: ...
Client(BoxBackend()).send(request) is a Box[Response] — the return
type is derived from the backend, no client code involved. For
APIClient.send the parsed
results of such a backend are typed Any (rewriting “the wrapper, around
the operation’s result type” for an arbitrary wrapper would need
higher-kinded types, which Python doesn’t have — only the shipped
wrappers are spelled out as overloads); everything works normally at
runtime.
If you want that precision for your wrapper too, the escape hatch is to
subclass APIClient and re-declare send()
with the wrapper spelled out — one cast is the price of the missing
higher-kinded types:
from typing import TypeVar
from typing import cast
from action0.client import APIClient
from action0.client import Backend
from action0.client import Operation
from action0.req import Response
R = TypeVar("R")
BoxBackendT_co = TypeVar("BoxBackendT_co", bound=Backend[Box[Response]], covariant=True)
class BoxAPIClient(APIClient[BoxBackendT_co]):
"""An APIClient whose send() results are precisely typed Boxes."""
# pyright ignore: its override check compares against every parent
# overload without filtering by this subclass's self type (none of
# the shipped-wrapper overloads can ever apply to a Box backend)
def send( # pyright: ignore[reportIncompatibleMethodOverride]
self, operation: Operation[R]
) -> Box[R]:
return cast(Box[R], super().send(operation))
Now BoxAPIClient(BoxBackend(), "https://api.example.com") sends an
Operation[Item] as a Box[Item] — for every operation, without
per-call casts, and the concrete backend type stays visible on
client.backend. mypy and ty accept the override as-is; pyright wants the
one suppression shown above. The pattern is pinned in the typing test
suite (tests/action0/client/test_typing.py), so it keeps working.