๐ Documentation
HTTP GET example
from pydantic import BaseModel
from clientele import api as clientele_api
import httpx
client = clientele_api.APIClient(base_url="https://api.example.com")
class User(BaseModel):
id: int
name: str
email: str
@client.get("/users/{user_id}")
def get_user(result: User, user_id: int, include_details: bool = True) -> User:
return result
user = get_user(42)
How Clientele works:
- Path parameters inside
{}are filled from the function arguments (e.g.user_id). - Any remaining keyword arguments (like
include_detailsabove) become query parameters, but you can also provide a dict function parameterquery={...}instead. - The
resultparameter is mandatory and its type annotation (User) drives response parsing. - Your function is injected with the
resultparameter - this is the response payload hydrated into yourresultparameter's type. Callers never supply it; Clientele injects it after the HTTP response arrives. - The function's return value is independent - you can return the result directly, transform it, or return something completely different.
HTTP POST example
from typing import TypedDict
from pydantic import BaseModel
from clientele import api as clientele_api
client = clientele_api.APIClient(base_url="https://api.example.com")
# Using Pydantic models
class CreateUserRequest(BaseModel):
name: str
class User(BaseModel):
id: int
name: str
@client.post("/users")
def create_user(*, result: User, data: CreateUserRequest) -> User:
return result
user = create_user(data=CreateUserRequest(name="Ada"))
# Or use TypedDict for the `data` and `result` parameters
class CreateUserRequestDict(TypedDict):
name: str
class UserDict(TypedDict):
id: int
name: str
@client.post("/users")
def create_user_with_dict(*, result: UserDict, data: CreateUserRequestDict) -> UserDict:
return result
# Pass dict directly - no instantiation needed
user = create_user_with_dict(data={"name": "Ada"})
How body-based methods (POST, PUT, PATCH, DELETE) work with Clientele:
- The request body must be supplied via the
datakeyword argument. - The
dataparameter can be a Pydantic model or a TypedDict. - For Pydantic models, the data is validated and serialized to JSON automatically.
- For TypedDict, the data is sent as-is (because TypedDict provides type hints without runtime validation).
- The
resultparameter is mandatory and determines how the response is parsed.
PUT, PATCH, and DELETE examples
from pydantic import BaseModel
from clientele import api as clientele_api
client = clientele_api.APIClient(base_url="https://api.example.com")
class UpdateUser(BaseModel):
name: str
email: str
class PatchNameUser(BaseModel):
name: str
class User(BaseModel):
id: int
name: str
# PUT with a full body
@client.put("/users/{user_id}")
def update_user(result: User, user_id: int, *, data: UpdateUser) -> User:
return result
updated = update_user(1, data=UpdateUser(name="New Name", email="[email protected]"))
# PATCH with partial data
@client.patch("/users/{user_id}")
def patch_user_name(result: User, user_id: int, *, data: PatchNameUser) -> User:
return result
patched = patch_user_name(1, data=PatchUserName(name="New Name"))
# DELETE that returns an empty response body
@client.delete("/users/{user_id}")
def delete_user(result: None, user_id: int) -> None:
return result
delete_user(1)
Return value independence
The result parameter defines what response you get from the API, but your function's return value is independent. This gives you flexibility:
# Return the result directly (most common)
@client.get("/users/{user_id}")
def get_user(result: User, user_id: int) -> User:
return result
# Return a derived value
@client.get("/users/{user_id}")
def get_user_email(result: User, user_id: int) -> str:
return result.email
# Return multiple values as a tuple
@client.post("/events")
def create_event(result: EventOut, data: EventIn) -> tuple[EventOut, str]:
log.info("Created event %s", result.id)
return result, "success"
Injected parameters
Clientele will inject the following parameters into your function once an http response is returned:
result: an instance of the type specified in theresultparameter annotation. This parameter is mandatory and its type annotation determines how the response is parsed. Can be a Pydantic model or a TypedDict.response: thehttpx.Response- useful for logging, debugging etc. (optional)
Both result and response must be declared first in your function signature, before any caller-supplied parameters (path params, query params, data).
For a full explanation of why this constraint exists, how Clientele hides these parameters from callers and type checkers, and how to configure mypy support, see Injected parameters & typing.
Response parsing rules
- If the response has a JSON content type, the payload is decoded from JSON.
- If the response type is not JSON then a
stris returned. - Empty body responses return
None. - The
resultparameter's type annotation drives response data validation. Pydantic models usemodel_validatefor runtime validation, while TypedDict provides type hints without runtime validation.
Custom response parsing
- You can provide a callable
response_parserto the decorator to handle your own response parsing. response_parserwill receive thehttpx.Responseobject.- The return type of the
response_parsermust match the type of theresultparameter. - You cannot provide
response_parserandresponse_mapping(see below) at the same time.
Example:
from clientele import api as clientele_api
import httpx
from pydantic import BaseModel
client = clientele_api.APIClient(base_url="http://localhost:8000")
class CustomResponseParserResponse(BaseModel):
name: str
other_value: str
# A custom handler for parsing the response
def custom_parser(response: httpx.Response) -> CustomResponseParserResponse:
data = response.json()
return CustomResponseParserResponse(name=data["name"], other_value="other value")
# Annotate the decorate to use, `result` type must match
@client.get("/users/{user_id}", response_parser=custom_parser)
def get_user_custom_response(result: CustomResponseParserResponse, user_id: int) -> str:
return result.other_value
Handling multiple response bodies and status codes
Real APIs often return different response models based on the HTTP status code. This is also a common feature of OpenAPI schemas.
The response_map parameter allows you to map status codes to specific Pydantic models or TypedDict classes, enabling proper type handling for success and error responses.
from pydantic import BaseModel
from clientele import api as clientele_api
client = clientele_api.APIClient(base_url="https://api.example.com")
class User(BaseModel):
id: int
name: str
class NotFoundError(BaseModel):
error: str
code: int
@client.get(
"/users/{user_id}",
response_map={
200: User,
404: NotFoundError,
}
)
def get_user(result: User | NotFoundError, user_id: int) -> User | NotFoundError:
return result
# Returns User for 200 responses
user = get_user(1)
match user:
case User():
print(f"Found user: {user.name}")
case NotFoundError() :
print(f"Error: {user.error}")
# For unexpected status codes, clientele.api.APIException is raised
try:
get_user(-999) # imagine the server returns 500
except clientele_api.APIException as e:
print(f"Unexpected status: {e.response.status_code}")
print(f"Reason: {e.reason}")
response_map requirements
- Keys must be valid HTTP status codes: Use the
codesenum fromclientele.apifor reference, or any standard HTTP status code integers (100-599). - Values must be Pydantic models or TypedDict: Each value must be a
BaseModelsubclass or aTypedDictclass. - Result parameter type must include all models: The
resultparameter's type annotation must be a Union containing all the Pydantic models or TypedDict classes used inresponse_map. - Unexpected status codes raise
APIException: If the server returns a status code not in theresponse_map, anAPIExceptionis raised with details about the unexpected status. - Precedence: If
response_mapprovides a model for the actual HTTP status code, that model is used. Otherwise, theresultparameter annotation is used as the default for 2xx responses.
Multi-status example with POST
If multiple statuses return the same response it is easy enough to extend the map:
@client.post(
"/users",
response_map={
201: User,
400: ValidationError,
422: ValidationError,
}
)
def create_user(
result: User | ValidationError, data: User
) -> User | ValidationError:
return result
# Handle different responses
response = create_user(data=User(name="Alice"))
match response:
case User():
print(f"Created user {response.id}")
case ValidationError():
print(f"Validation failed: {response.errors}")
Connection persistence
Clientele API will generate a singleton instance for the async and sync http clients. When you import the module and issue multiple function calls it will use the same http connection.