Generate type-safe HTTP clients from OpenAPI specifications.
Clientify produces both synchronous and asynchronous Python client code with full type annotations, enabling IDE autocompletion and static type checking with tools like pyright and mypy.
- Zero-Cost Type Safety: Uses
TypedDictfor response models with no runtime validation overhead. JSON responses are used directly without object instantiation or schema validation. - Single Implementation: Uses
@overloadfor path-specific type hints while maintaining a single runtime implementation. No per-endpoint method generation or dynamic dispatch overhead. - No Generated Dependencies: Generated code relies only on the Python standard library. You bring your own HTTP client (httpx, requests, etc.) via the Backend protocol - no additional packages required.
- Streaming Support: Built-in support for Server-Sent Events (
text/event-stream) withIterator[str]/AsyncIterator[str]for streaming responses. - Sync & Async: Generates both synchronous and asynchronous client classes
- Backend Agnostic: Works with any HTTP library that implements the simple Backend protocol (httpx, requests, aiohttp, etc.)
- Python 3.10+: Modern Python syntax with union types (
X | Y) and other features
Run clientify using uvx (no installation required):
uvx --from git+https://github.com/altescy/clientify clientify openapi.yaml -n myapi -o ./generatedThis creates a Python package with:
generated/
βββ myapi/
βββ __init__.py # Public exports
βββ client.py # SyncClient and AsyncClient classes
βββ models.py # Dataclass models for schemas
βββ types.py # Response type definitions
import httpx
from myapi import SyncClient, AsyncClient
# Synchronous client
with httpx.Client() as backend:
client = SyncClient(base_url="https://api.example.com", backend=backend)
# Type-safe API calls
response = client.get("/users")
users = response.body # Fully typed based on OpenAPI spec
# With parameters
response = client.post(
"/users",
body={"name": "Alice", "email": "alice@example.com"},
content_type="application/json",
)
# Asynchronous client
async with httpx.AsyncClient() as backend:
client = AsyncClient(base_url="https://api.example.com", backend=backend)
response = await client.get("/users/{user_id}", params={"path": {"user_id": 123}})
user = response.bodyclientify <spec> [options]| Argument | Description |
|---|---|
spec |
Path or URL to OpenAPI spec file (YAML or JSON) |
| Option | Description | Default |
|---|---|---|
--package-name, -n |
Name of the generated package | client |
--output-dir, -o |
Output directory | . |
--python-version |
Target Python version | 3.10 |
# Basic usage
clientify api.yaml -n myapi -o ./src
# Target specific Python version
clientify api.yaml -n myapi --python-version 3.12
# From URL
clientify https://api.example.com/openapi.json -n myapi
# Using uvx (no installation required)
uvx --from git+https://github.com/altescy/clientify clientify api.yaml -n myapi
# Using uvx with a specific branch or tag
uvx --from git+https://github.com/altescy/clientify@main clientify api.yaml -n myapiThe generated clients accept any backend that implements a simple protocol:
from typing import Protocol, Iterator
class SyncBackend(Protocol):
def request(
self,
method: str,
url: str,
*,
content: bytes | None = None,
headers: Mapping[str, str] | None = None,
timeout: float | None = None,
) -> Response: ...
class AsyncBackend(Protocol):
async def request(
self,
method: str,
url: str,
*,
content: bytes | None = None,
headers: Mapping[str, str] | None = None,
timeout: float | None = None,
) -> Response: ...httpx clients directly satisfy the Backend protocol:
import httpx
from myapi import SyncClient, AsyncClient
# httpx.Client works directly as SyncBackend
client = SyncClient(base_url="https://api.example.com", backend=httpx.Client())
# httpx.AsyncClient works directly as AsyncBackend
async_client = AsyncClient(base_url="https://api.example.com", backend=httpx.AsyncClient())For requests, create a simple wrapper:
import requests
from myapi import SyncClient
class RequestsBackend:
def __init__(self) -> None:
self.session = requests.Session()
def request(self, method: str, url: str, *, content=None, headers=None, timeout=None):
response = self.session.request(
method=method,
url=url,
data=content,
headers=headers,
timeout=timeout,
)
return response
client = SyncClient(base_url="https://api.example.com", backend=RequestsBackend())class SyncClient:
def __init__(
self,
base_url: str,
backend: SyncBackend,
headers: Mapping[str, str] | None = None,
raise_on_unexpected_status: bool = True,
) -> None: ...
# HTTP methods with overloads for each endpoint
@overload
def get(self, url: Literal["/users"], ...) -> GetUsersResponse: ...
@overload
def get(self, url: Literal["/users/{user_id}"], ...) -> GetUserResponse: ...
def get(self, url: str, ...) -> Response: ...
def post(self, url: str, *, body: object, ...) -> Response: ...
def put(self, url: str, *, body: object, ...) -> Response: ...
def delete(self, url: str, ...) -> Response: ...
def patch(self, url: str, *, body: object, ...) -> Response: ...from myapi.types import SuccessResponse, ErrorResponse
response = client.get("/users")
if isinstance(response, SuccessResponse):
print(response.status) # int (200-299)
print(response.headers) # dict[str, str]
print(response.body) # Typed based on OpenAPI response schemaParameters are passed via the params dict with typed sub-dicts:
response = client.get(
"/users/{user_id}/posts",
params={
"path": {"user_id": 123}, # Path parameters
"query": {"limit": 10, "offset": 0}, # Query parameters
"header": {"X-Request-ID": "abc"}, # Header parameters
},
)Clientify supports Python 3.10 and above. The generated code adapts to the target Python version:
| Python Version | Features Used |
|---|---|
| 3.10+ | X | Y union syntax, match statements |
| 3.11+ | Self type, ExceptionGroup |
| 3.12+ | Type parameter syntax (class Foo[T]:) |
Specify the target version:
clientify api.yaml -n myapi --python-version 3.12git clone https://github.com/altescy/clientify.git
cd clientify
uv sync# Run all checks (format, lint, test)
make all
# Individual commands
make format # Format code with ruff
make lint # Run ruff and pyright
make test # Run pytest# All tests
uv run pytest
# Specific test file
uv run pytest tests/generation/test_client.py -v
# Integration tests only
uv run pytest tests/integration/ -vMIT License - see LICENSE for details.
Contributions are welcome! Please feel free to submit a Pull Request.
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Make your changes
- Run
make allto ensure tests pass - Commit your changes (
git commit -m 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request