Skip to content

Repository files navigation

Action0-Client

CI PyPI

Backend-agnostic, fully typed HTTP API clients: describe your API once — as typed operations — and run it synchronously, on asyncio or on Twisted, just by plugging in a different backend. The type checker follows along: the same send() returns a value, an Awaitable or a Deferred, depending on the backend. Built on action0-req (request/response representation) and action0-url (URL representation).

The same typed operation, driven by three different backends:

client = APIClient(RequestsBackend(), "https://api.example.com/v1")
item = client.send(GetItem(item_id=42))  # Item

client = APIClient(AsyncHttpxBackend(), "https://api.example.com/v1")
item = await client.send(GetItem(item_id=42))  # Awaitable[Item]

client = APIClient(TwistedBackend(), "https://api.example.com/v1")
deferred = client.send(GetItem(item_id=42))  # Deferred[Item]

(GetItem is an ordinary typed operation class, written once — see Usage for its definition.)

Requires Python 3.11 or newer.

Full documentation including the API reference: https://laughinjar.github.io/action0-client/

Status: the core API is complete — the backend protocol with requests/httpx/Twisted implementations and instrumentation hooks, the raw Client, the typed Operation/JsonOperation/APIClient layer, and the stub backends for testing.

Usage

Raw requests, any execution model

A backend implements one structural protocol, Backend[W], generic over what its send wraps the response in (W is Response, Awaitable[Response], Deferred[Response], Future[Response], ...); Client sends action0-req Requests through it, and the return type of send() follows the backend:

from action0.client import Client
from action0.client.backends.requests import RequestsBackend
from action0.req import Request

with RequestsBackend() as backend:
    response = Client(backend).send(Request("https://example.com/"))  # Response
from action0.client.backends.httpx import AsyncHttpxBackend

async with AsyncHttpxBackend() as backend:
    response = await Client(backend).send(Request("https://example.com/"))
from action0.client.backends.twisted import TwistedBackend

deferred = Client(TwistedBackend()).send(Request("https://example.com/"))
deferred.addCallback(lambda response: print(response.status))  # Deferred[Response]

Typed APIs: operations

Endpoints are dataclasses: HTTP method and path template are fixed on the class, the variable parts are typed fields placed via specifiers (query, header, path_param, json_field, json_body, form_field, body), and the generic parameter is the parsed result type:

from dataclasses import dataclass
from typing import Any
from action0.client import APIClient, JsonOperation, path_param, query
from action0.req import Method


@dataclass
class Item:
    id: int
    name: str


class GetItem(JsonOperation[Item]):
    method = Method.GET
    path = "/items/{item_id}"

    item_id: int = path_param()
    expand: bool | None = query(default=None)  # None = not sent

    def load_json(self, data: Any) -> Item:
        return Item(id=data["id"], name=data["name"])

APIClient binds backend + base URL + default headers and runs the whole pipeline — request building, send, status check, parsing — with the result type following operation and backend (checked by mypy strict, pyright and ty):

client = APIClient(RequestsBackend(), "https://api.example.com/v1")
item = client.send(GetItem(item_id=42))  # Item

client = APIClient(AsyncHttpxBackend(), "https://api.example.com/v1")
item = await client.send(GetItem(item_id=42))  # Awaitable[Item]

client = APIClient(TwistedBackend(), "https://api.example.com/v1")
deferred = client.send(GetItem(item_id=42))  # Deferred[Item]

Transport problems surface uniformly as TransportError/TimeoutError, API-level problems (unexpected status, malformed payload) as APIError — regardless of the HTTP library underneath.

Instrumentation and testing

Backends run Hooks (logging, metrics, tracing, request decoration) around every send — the bundled LoggingHook logs redacted requests and responses with timings. action0.client.testing ships recording stub backends for all three execution models, so API clients are testable without a server:

from action0.client.testing import StubBackend
from action0.req import Response

backend = StubBackend(Response(200, body='{"id": 42, "name": "Thing"}'))
client = APIClient(backend, "https://api.example.com/v1")
assert client.send(GetItem(item_id=42)) == Item(id=42, name="Thing")
assert backend.requests[0].url.path == "/v1/items/42"

A complete example client (models, operations, auth, all three execution models, runnable demo) lives in examples/petstore.py.

Installation

Install from PyPI. The HTTP libraries are optional extras — pick what you need (requests, httpx, aiohttp, urllib3, twisted, all); the stdlib urllib and thread-pool backends work without any extra:

uv add "action0-client[httpx]"

Development

The project is managed with uv; uv run creates and syncs the virtual environment automatically (the dev group includes all backend libraries):

uv run pytest        # run the tests (incl. the docstring examples as doctests)
uv run ruff check    # lint
uv run ruff format   # format
uv run mypy          # type-check (also: uv run pyright, uv run ty check)

# build the docs (Sphinx; deployed to GitHub Pages on push to main)
uv run --group docs sphinx-build -W docs docs/_build/html

Releasing

The version lives only in src/action0/client/__init__.py (__version__). To release: bump it, merge to main, then tag the release commit and push the tag — the release workflow re-runs all checks, verifies the tag matches __version__, builds sdist + wheel and publishes to PyPI via trusted publishing:

git tag v0.1.0
git push origin v0.1.0

AI-assisted development

In the spirit of transparency: most of this project's code, tests and documentation are written by Claude Code, Anthropic's coding agent — under human direction and review. The designs are specified, discussed and iterated by a human, and every change is reviewed before it lands in main or in a release. AI-authored commits carry a Co-Authored-By: Claude ... trailer.

About action0

This is just the namespace I like to use for my personal projects. I quite like namespaces.

About

Backend-agnostic, fully typed HTTP API clients — sync, async or Twisted, decided by the backend you plug in

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages