A declarative, type-safe API client framework for Python
Project description
clientcraft
A declarative, type-safe API client framework for Python 3.12+.
Define your HTTP API as a class of typed annotations and get fully-typed, Pydantic-validated request/response handling for free — no boilerplate per endpoint.
📖 Full documentation: https://sunbright-technologies.github.io/clientcraft/
Features
- Declarative endpoint definitions using type annotations
- Type-safe request and response handling with Pydantic
- Sync and async client support
- Pluggable backends: urllib (no deps), requests, httpx, aiohttp, or custom
- Multiple response types: JSON, text, bytes, or no content
- Parameterless endpoints: declare
Get[None, ...]for endpoints that take no request - Declarative error handling: map HTTP errors to domain exceptions per endpoint or client-wide
- Testing helpers: a fake backend and client stubs to test both sides without the network
Installation
# Using uv (urllib backend works with no extra dependencies)
uv add clientcraft
# With a specific backend
uv add "clientcraft[requests]"
uv add "clientcraft[httpx]"
uv add "clientcraft[aiohttp]"
# With all optional backends
uv add "clientcraft[all]"
Quick Start
Define your API
from typing import Literal
from pydantic import BaseModel
from clientcraft import Get, Post, Delete
from clientcraft.client import APIClient
# Define request/response models
class GetUserRequest(BaseModel):
user_id: str
class User(BaseModel):
id: str
name: str
email: str
class CreateUserRequest(BaseModel):
name: str
email: str
class DeleteUserRequest(BaseModel):
user_id: str
# Define your API client declaratively
class UserAPI(APIClient):
get_user: Get[GetUserRequest, User, Literal["/users/{user_id}"]]
create_user: Post[CreateUserRequest, User, Literal["/users"]]
delete_user: Delete[DeleteUserRequest, None, Literal["/users/{user_id}"]]
Use your API
Concrete backends live in their own submodules and must be imported from there
(clientcraft.backends itself only exposes the protocols):
from clientcraft.backends.requests import RequestsBackend
# Create client with a backend
backend = RequestsBackend()
client = UserAPI(base_url="https://api.example.com", backend=backend)
# Make requests - fully typed!
user = client.get_user(GetUserRequest(user_id="123"))
print(user.name) # Type checker knows this is a User
# Create a user
new_user = client.create_user(CreateUserRequest(name="Alice", email="alice@example.com"))
The standard-library UrllibBackend requires no third-party dependencies:
from clientcraft.backends.urllib import UrllibBackend
with UrllibBackend() as backend:
client = UserAPI(base_url="https://api.example.com", backend=backend)
user = client.get_user(GetUserRequest(user_id="123"))
Async Usage
from typing import Literal
from clientcraft import AsyncGet, AsyncPost
from clientcraft.async_client import AsyncAPIClient
from clientcraft.backends.aiohttp import AiohttpBackend
class AsyncUserAPI(AsyncAPIClient):
get_user: AsyncGet[GetUserRequest, User, Literal["/users/{user_id}"]]
create_user: AsyncPost[CreateUserRequest, User, Literal["/users"]]
async with AiohttpBackend() as backend:
client = AsyncUserAPI(base_url="https://api.example.com", backend=backend)
user = await client.get_user(GetUserRequest(user_id="123"))
Endpoint Types
| Sync | Async | HTTP Method | Request Style | Description |
|---|---|---|---|---|
Get |
AsyncGet |
GET | Query params | Read operations |
Post |
AsyncPost |
POST | JSON body | Create operations |
Put |
AsyncPut |
PUT | JSON body | Full update |
Patch |
AsyncPatch |
PATCH | JSON body | Partial update |
Delete |
AsyncDelete |
DELETE | Query params | Delete operations |
Each is parameterized as Endpoint[RequestModel, ResponseModel, Literal["/path"]].
Path parameters ({user_id}) are pulled from the request model; remaining fields
become the query string (GET/DELETE) or JSON body (POST/PUT/PATCH).
Parameterless Endpoints
For endpoints that take no parameters at all, declare the request type as None.
The endpoint can then be called with no argument (or an explicit None):
from typing import Literal
from clientcraft import Get
from clientcraft.client import APIClient
class StatusAPI(APIClient):
list_users: Get[None, UserList, Literal["/users"]]
client = StatusAPI(base_url="https://api.example.com", backend=backend)
users = client.list_users() # no argument
users = client.list_users(None) # explicit None — equivalent
Response Types
from typing import Literal
from clientcraft import Get, Delete, TextResponse, BytesResponse
class FilesAPI(APIClient):
# JSON response (default) — parsed into the Pydantic model
get_user: Get[GetUserRequest, User, Literal["/users/{user_id}"]]
# Text response — returned as TextResponse(content=...)
health_check: Get[None, TextResponse, Literal["/health"]]
# Binary response — returned as BytesResponse(content=...)
download: Get[GetFileRequest, BytesResponse, Literal["/files/{file_id}"]]
# No response body (e.g. 204 No Content) — returns None
delete_user: Delete[DeleteUserRequest, None, Literal["/users/{user_id}"]]
Error Handling
By default any response with status >= 400 raises HttpError. Opt into your own
domain exceptions declaratively — no try/except on status codes at every call
site:
from typing import Annotated, Literal
from clientcraft import DEFAULT, DomainError, ErrorMap, Get, Raises
from clientcraft.client import APIClient
class UserNotFound(DomainError): ...
class RateLimited(DomainError): ...
class ApiError(DomainError): ...
class UserAPI(APIClient):
get_user: Annotated[
Get[GetUserRequest, User, Literal["/users/{user_id}"]],
Raises(404, UserNotFound), # per-endpoint
]
errors = ErrorMap({429: RateLimited, DEFAULT: ApiError}) # client-wide + catch-all
Resolution order: per-endpoint Raises → client errors → handle_error
(override for full control). An exception needing the response body can parse it in
DomainError.from_http_error. See the
error handling guide
and examples/error_handling.py.
Testing
clientcraft.testing fakes either side of the client — no live server needed.
Test your client with FakeBackend: fake the transport, run the real client
(serialization, parsing, and error handling all run). Routes are backed by
unittest.mock.Mocks:
from clientcraft.testing import FakeBackend
backend = FakeBackend()
with backend.mock_get("/users/1", json={"id": "1", "name": "Ada"}) as m:
client = UserAPI(base_url="https://api.example.com", backend=backend)
assert client.get_user(GetUserRequest(user_id="1")).name == "Ada"
m.assert_called_once()
Test code that uses your client with mock_client: inject a stubbed client
whose endpoints are mocks (no backend needed):
from clientcraft.testing import mock_client, mock_of
client = mock_client(UserAPI, get_user=User(id="1", name="Ada"))
# inject `client` into your service, exercise it, then:
mock_of(client, "get_user").assert_called_once_with(GetUserRequest(user_id="1"))
See the testing guide
and examples/testing_your_client.py /
examples/testing_your_app.py.
Backends
| Backend | Module | Sync/Async | Extra |
|---|---|---|---|
UrllibBackend |
clientcraft.backends.urllib |
Sync | none (stdlib) |
RequestsBackend |
clientcraft.backends.requests |
Sync | requests |
HttpxBackend |
clientcraft.backends.httpx |
Sync | httpx |
HttpxAsyncBackend |
clientcraft.backends.httpx |
Async | httpx |
AiohttpBackend |
clientcraft.backends.aiohttp |
Async | aiohttp |
Backends are protocol-based — any object implementing the HttpBackend /
AsyncHttpBackend protocol works, so you can supply your own.
Examples
Runnable examples live in examples/:
| File | Shows |
|---|---|
basic_usage.py |
A declarative async client against the live PokeAPI |
error_handling.py |
HTTP errors → domain exceptions |
testing_your_client.py |
Testing a client with FakeBackend |
testing_your_app.py |
Testing app code with mock_client |
Development
# Clone and setup
cd clientcraft
uv sync --all-extras
# Run tests
uv run pytest
# Type checking
uv run mypy src tests examples
# Linting and formatting
uv run ruff check src tests examples
uv run ruff format --check src tests examples
License
MIT
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file clientcraft-0.1.5.tar.gz.
File metadata
- Download URL: clientcraft-0.1.5.tar.gz
- Upload date:
- Size: 149.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9b2f8e8d16a7231fab6be31590be740246bcee0514c73c6d5aea5e0dabcdec86
|
|
| MD5 |
e34be625b54d6773d71dedce54b0639c
|
|
| BLAKE2b-256 |
83738078553697d5b43e6e0a019088baa153af36cab5442879c72960fbe4d5c7
|
Provenance
The following attestation bundles were made for clientcraft-0.1.5.tar.gz:
Publisher:
publish.yml on SunBright-Technologies/clientcraft
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
clientcraft-0.1.5.tar.gz -
Subject digest:
9b2f8e8d16a7231fab6be31590be740246bcee0514c73c6d5aea5e0dabcdec86 - Sigstore transparency entry: 2186010808
- Sigstore integration time:
-
Permalink:
SunBright-Technologies/clientcraft@defbbe79eb2119d681e71694572781acb510af37 -
Branch / Tag:
refs/tags/v0.1.5 - Owner: https://github.com/SunBright-Technologies
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@defbbe79eb2119d681e71694572781acb510af37 -
Trigger Event:
release
-
Statement type:
File details
Details for the file clientcraft-0.1.5-py3-none-any.whl.
File metadata
- Download URL: clientcraft-0.1.5-py3-none-any.whl
- Upload date:
- Size: 31.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f5bdcff65824de906fad01264f13bfa939b32a39a945626600a4b05d203973e3
|
|
| MD5 |
1f28267186c9e63d31e5dbb1fa5883ee
|
|
| BLAKE2b-256 |
079b583ca3352ce114ba35acc89869c6841b927205a181382d1ffd26ecb8cdf7
|
Provenance
The following attestation bundles were made for clientcraft-0.1.5-py3-none-any.whl:
Publisher:
publish.yml on SunBright-Technologies/clientcraft
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
clientcraft-0.1.5-py3-none-any.whl -
Subject digest:
f5bdcff65824de906fad01264f13bfa939b32a39a945626600a4b05d203973e3 - Sigstore transparency entry: 2186011047
- Sigstore integration time:
-
Permalink:
SunBright-Technologies/clientcraft@defbbe79eb2119d681e71694572781acb510af37 -
Branch / Tag:
refs/tags/v0.1.5 - Owner: https://github.com/SunBright-Technologies
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@defbbe79eb2119d681e71694572781acb510af37 -
Trigger Event:
release
-
Statement type: