Python SDK reference
This page lists the options and behaviour of each part of the Python SDK. Start with the SDK page to install it and moderate your first content.
Configuration
CalmLensServerClient(...) takes these keyword arguments:
| Argument | Default | Meaning |
|---|---|---|
api_key | CALMLENS_API_KEY | Project API key. |
project_id | CALMLENS_PROJECT_ID | Project ID. |
base_url | https://api.calmlens.com/v1 | API endpoint. |
cdn_base_url | https://cdn.calmlens.com/v1 | CDN endpoint. |
timeout | 120 | Seconds a connection may stay silent before sdk/timeout. It limits inactivity, not the whole transfer, so large uploads and downloads are not cut off. |
upload_timeout | None | The timeout of binary uploads (file content and multipart parts); None uses timeout. Text uploads always use timeout. |
connect_timeout | 10 | Seconds to open a connection, including the proxy CONNECT and the TLS handshake. |
max_retries | 2 | Retries for requests that are safe to resend (see Retries and idempotency); 0 disables them. |
max_response_bytes | 33554432 (32 MiB) | The largest API response body the client reads. A larger body, or a larger declared Content-Length before reading, raises sdk/response-too-large. CDN downloads are not limited. |
transport | Pooled http.client connections | A transport(request, timeout) callable returning a context-managed response with status, headers and read(). |
Invalid options raise CalmLensValidationError with sdk/invalid-config before the client creates any state. client.with_options(timeout=..., upload_timeout=..., max_retries=..., max_response_bytes=...) returns a client that shares the connections and lens cache with other settings, such as client.with_options(timeout=300).cdn.serve(...); options you leave out keep the original client's values.
Client lifecycle
Reuse one client: it keeps HTTP connections open between calls and is thread-safe. Close its connection pool with client.close(), or use it as a context manager (with CalmLensServerClient() as client:). A custom transport stays yours to close. After os.fork(), as in gunicorn, Celery or multiprocessing workers, the child opens its own connections instead of sharing the parent's sockets.
Redirects are not followed, so the API key is only sent to the configured endpoint: a 3xx response raises sdk/http-error with its status and request ID, and is not retried, including for cdn.serve. repr(client) never includes the API key.
API requests send Accept: application/json, and every request sends User-Agent: calmlens-python/<version> (<implementation>/<Python version>), such as calmlens-python/0.18.0 (cpython/3.12.4). The SDK version is calmlens.__version__.
Proxies
The default transport follows HTTP_PROXY, HTTPS_PROXY and NO_PROXY, including user:password@ credentials in the proxy URL. HTTPS requests tunnel through the proxy with CONNECT, so TLS runs end to end to CalmLens. Point HTTPS_PROXY at the proxy's http:// address: an https:// proxy URL is not supported for HTTPS requests and raises sdk/invalid-config.
Arguments and responses
Each method takes snake_case keyword arguments, or one options dict with the API's camelCase keys, but not both:
client.assets.retrieve(asset_id=asset_id)
client.assets.retrieve({"assetId": asset_id})With keyword arguments, the keys of nested dicts are converted to camelCase too, except inside metadata values and transform options, which are sent as written. An options dict is sent as given, so write its nested keys in camelCase.
Responses are plain dicts with the API's camelCase field names, typed as TypedDicts in calmlens.types. Fields added in a newer API version stay in the dict, and enum values such as classification.moderation.status, classification.status, type and visibility are plain strings, so a value this SDK version doesn't list never fails. Treat a moderation status you don't recognise as not allowed.
Errors
Every failure raises CalmLensError or a subclass, with a stable code. The constants in calmlens.error_codes are generated from the API's error codes.
| Exception | Raised when | code | status |
|---|---|---|---|
CalmLensAPIError | The API answers with a non-2xx response. | The API's code, such as 404 asset/not-found; sdk/http-error without an error body. | HTTP status |
CalmLensTimeoutError | Connecting or waiting for data timed out. | sdk/timeout | None |
CalmLensTransportError | Any other network or protocol failure: refused, reset, DNS, TLS or a truncated response. | sdk/transport-error | None |
CalmLensValidationError | Arguments or configuration are invalid, before anything is sent. It is also a ValueError. | sdk/invalid-argument, sdk/invalid-config, or the API's code for the same problem, such as 400 upload/invalid-payload or 400 upload/invalid-mime-type. | None |
CalmLensError | A 2xx response isn't the JSON the operation returns, or is larger than max_response_bytes. | sdk/invalid-response, sdk/response-too-large | HTTP status |
CalmLensError | An iterator received a page that says more follow, with a cursor it had already sent; details["cursor"] holds it. | sdk/invalid-response | None |
CalmLensError | assets.wait_for_decision saw no decision within its timeout. | sdk/timeout | None |
WebhookVerificationError | A webhook delivery fails verification or isn't a CalmLens event. | sdk/invalid-webhook-signature, sdk/invalid-webhook-payload | None |
Each error also has:
| Attribute | Value |
|---|---|
message | A readable description. |
details | The API error body's details object, such as validation issues; empty for transport errors. |
request_id | The response's X-Request-Id, or the error body's requestId without one. Quote it to support. |
headers | The response headers, with lower-case names. |
retry_after | Whole seconds from Retry-After, sent in seconds or as an HTTP date. |
is_not_found | Whether the resource doesn't exist. |
is_retryable | Depends only on code and status, so every error class agrees: True for sdk/transport-error, sdk/timeout (including wait_for_decision's), 409 idempotency/request-in-progress and HTTP 429, 502, 503 and 504. |
Transport errors keep the native exception, such as ConnectionResetError, ssl.SSLError, http.client.IncompleteRead or TimeoutError, as __cause__.
Retries and idempotency
The client retries a request only when sending it again can't duplicate work: GET requests, and uploads (assets.create, assets.classify, text.classify and assets.upload_part), which always carry an Idempotency-Key. It retries network failures, timeouts, 429, 502, 503, 504 and 409 idempotency/request-in-progress, up to max_retries times. Retry n, counting from 0, waits between half and all of min(8, 0.5 × 2ⁿ) seconds, or the Retry-After delay when the response sends one. Retry-After: 0 retries at once, and a Retry-After over 60 seconds raises the error instead.
Other writes are never retried: update, move, retry, explain, decide, batch, deletes, folder and hash list changes, signed URLs and lens definitions. cdn.serve retries network failures, timeouts and 429, 502, 503 and 504 the same way, and returns the last response once retries run out.
Each upload call generates one Idempotency-Key and reuses it for its own retries. The API replays the saved response for 24 hours instead of creating a second asset. To retry from another process, pass idempotency_key=, such as a key derived from your own record ID. Reusing a key for a different request raises 409 idempotency/key-reused. A file body is rewound for a retry when it can seek; uploads from streams that can't seek are not retried. See Retrying requests safely.
Assets
page = client.assets.list(page_size=20, ordering=[{"field": "createdAt", "direction": "desc"}])
for asset in client.assets.iter(filter="classification.moderation.status==allowed"): # requests pages lazily
print(asset["id"])
children = client.assets.list_children(asset_id=asset_id)
client.assets.update(asset_id=asset_id, description=None, tags=["reviewed"])
client.assets.move(asset_id=asset_id, folder_id=folder_id)
client.assets.move(asset_id=asset_id, folder_id=None) # project root
client.assets.retry(asset_id=failed_asset_id) # a failed asset whose content is still held
client.assets.delete_content(asset_id=asset_id) # keeps the record and report
client.assets.delete(asset_id=asset_id) # returns None
client.assets.batch(asset_ids=[first_id, second_id], operation="remove-content") # returns {"queued": True}- List pagination starts with no
cursor; see Pagination. assets.decide(asset_id=..., decision="blocked", note="Spam")records a reviewer's decision. Purging also needsconfirm_purge=True.assets.explain(asset_id=..., rerun=True)moderates the asset again under the current policy; withoutrerunit returns the policy evidence.assets.batchtakes up to 500 asset IDs and the operation"delete"or"remove-content", and runs in the background once queued.assets.create_signed_url(asset_id=..., ...)signs therenderedresource for 900 seconds unless you passresourceandexpires_in_seconds(1 to 86,400). It takes the samelens,transformandviewer_claimsas the CDN methods.
Waiting for a decision
assets.wait_for_decision(asset_id=..., timeout=60) retrieves the asset after 0.5 seconds, then waits 1.5 times longer each time, up to 5 seconds between checks and never past timeout. It returns once classification.moderation.status is no longer pending or classification.status is failed. Without a decision in time it raises CalmLensError with sdk/timeout and details {"assetId": ..., "classificationStatus": ..., "moderationStatus": ...}, the last statuses it saw. It takes no cancellation argument and blocks the calling thread for at most timeout seconds plus one request, so pass a shorter timeout to stop sooner. Webhooks report decisions without polling.
Asset fields
Asset methods return the asset as a dict typed calmlens.types.Asset, such as asset["externalId"] and asset["classification"]["moderation"]["status"]. id, projectId, name, type, createdAt, upload, classification and content are always present. Fields that don't apply inside classification and content are None. description, externalId, externalUrl, folderId, parentId, metadata and tags may be missing or None, so read them with .get().
See Reading classification results for the report and Asset lifecycle for upload, processing and content statuses, failure reasons and removal reasons.
Uploads
assets.create and assets.classify take exactly one of content, url or part. text.classify(text=...) sends UTF-8 text/plain content. A content or part payload has exactly one of:
| Key | Value |
|---|---|
plainText (plain_text) | A string, sent as UTF-8. |
base64 | Base64 or a data URL. |
data | bytes, bytearray, memoryview, or a binary file object, such as open(path, "rb"), io.BytesIO or a socket file. |
File objects are streamed in chunks, never read into memory. The size is measured for bytes and for files that can seek, from the current position. A stream that can't seek, such as a pipe, socket or generator wrapped in a reader, is sent to its end with chunked transfer encoding unless you pass sizeInBytes; multipart parts from such streams need sizeInBytes. Exactly sizeInBytes bytes are sent, and a stream that ends early raises 400 upload/invalid-payload. Files opened in text mode are rejected. Give a concrete MIME type, such as image/jpeg, on the first request.
Raw uploads carry the asset's other fields and processing options in the query string; uploads from a url send JSON.
Multipart uploads
upload = client.assets.create(
name="large.png",
total_size_in_bytes=12 * 1024 * 1024, # optional: checked before any part is stored
part={"type": "image/png", "data": first_part}, # at least 5 MiB
)
completed = client.assets.upload_part(
asset_id=upload["id"],
part={"data": final_part, "partNumber": 2, "isFinal": True},
)Send parts in order; every part but the last is at least 5 MiB. Multipart image uploads total 10 to 20 MiB; smaller images go in one request. Split the bytes before base64-encoding each part. The API saves whether the upload started with create (queued) or classify (wait for the decision) and applies it to the final part, from any client or process; pass queue= to upload_part only to override it. With total_size_in_bytes, parts that don't add up to the declared size raise 400 upload/size-mismatch. See Multipart uploads.
Replies in a conversation
For a reply, pass who wrote it and the earlier posts in the conversation, oldest first. CalmLens moderates only the reply, and uses the earlier posts to understand who it addresses and what it refers to. author_id is your own label for a person; CalmLens only compares it with the author IDs of the earlier posts.
asset = client.text.classify(
text="Same to you",
author_id="user-9",
context={"items": [
{"text": "You're an idiot", "authorId": "user-1"},
{"text": "Calm down, both of you"},
]},
)assets.create and assets.classify take the same options. The SDK sends only the posts CalmLens uses: the 5 most recent that fit within 2,000 characters, always including the most recent one. select_moderation_context_items(items) applies the same rule. The asset's authorId and context hold what was used, and context becomes None once the content is removed. See Threaded moderation.
Upload tokens
signed = client.upload_tokens.sign(
upload_preset_id="preset_4Lk8Qw2Xm9Rt5Vb3Nc7P",
external_id="post-42", # optional: the uploaded asset gets this external ID
expires_in_seconds=600, # optional: 1 to 3600, 600 by default
)
# signed["token"], signed["expiresAt"] (milliseconds since the Unix epoch)Signing happens locally with your API key and makes no request. See Upload presets.
Folders
folders.list() returns a cursor page with items, the visible ancestors, nextCursor, hasMore, pageSize and ordering. Set parent_id to open a branch, or omit it for the project root. page_size is 1 to 100 (default 50), and ordering takes one {"field": "name" | "createdAt", "direction": "asc" | "desc"} entry (by name, ascending, by default). search keeps the children whose names contain it. folders.move(folder_id=..., parent_id=None) moves a folder to the root. See Organising assets.
Hash lists
A "flag" list reports image uploads that match its entries, and a "block" list also blocks close matches. Add a PDQ hash, or the hash of an image asset in the project:
removed = client.hash_lists.create(name="Removed images", action="block")
client.hash_lists.add_entry(hash_list_id=removed["id"], asset_id=asset_id, label="Report 1042")
entries = client.hash_lists.list_entries(hash_list_id=removed["id"], page_size=50) # entries["nextCursor"]hash_lists.list(cursor=..., page_size=...) returns one page, newest first. hash_lists.delete and hash_lists.remove_entry return None. See Hash lists.
Lenses
client.ensure_lens(identifier="product-thumbnail", name="Product thumbnail", type="image",
active=True, version=1, transforms=[{"type": "imageTransform", "options": {"width": 128}}])
signed = client.assets.create_signed_url(asset_id=asset_id, resource="rendered", lens="product-thumbnail")ensure_lens and ensure_lenses cache each definition by its full content and version, and concurrent calls for the same definition share one request. A new version replaces the cached definition for that identifier. Version conflicts from the API, such as 409 lens/version-conflict, are raised. See Lenses and transforms.
CDN
url = client.cdn.get_asset_url(asset_identifier=asset_id, lens="thumbnail")
with client.cdn.serve(asset_identifier=asset_id) as response:
if response.ok:
with open("copy.jpg", "wb") as out:
for chunk in response.iter_bytes(): # streamed
out.write(chunk)resource is rendered (the default), original or placeholder. Only rendered takes a lens, a transform or both; the lens applies first. A transform is a selector string or one or more structured steps in the shape of a lens's transforms, and both forms also work for assets.create_signed_url:
client.cdn.get_asset_url(asset_identifier=asset_id, transform="format=png,width=1024")
client.cdn.get_asset_url(asset_identifier=asset_id,
transform={"type": "imageTransform", "options": {"format": "png", "width": 1024}})A selector has one path segment per step, such as fit=cover,width=640/format=webp, and a lens and a transform are joined with / (/rendered/@thumbnail/width=320). The SDK sorts structured options, writes nested options dotted (border.color), drops the # from hex colours and percent-encodes values, so both calls above return the same URL. The redact option ({"text": True, "personalInformation": True}, or redact.text=true in a selector) blurs the text regions OCR found when the image was processed; see Text regions and redaction.
cdn.serve returns a CDNResponse even for non-2xx responses, after retrying the retryable ones. It has status, ok, headers, content_type, request_id, read(), iter_bytes() and close(); check response.ok and close it, or use it as a context manager. Use signed URLs for private content in browsers.
Viewer claims
The CDN methods and assets.create_signed_url take viewer_claims with region and ageConfirmed. An asset whose moderation status is age-restricted is served only with viewer_claims={"ageConfirmed": True}, even with a signed URL or your API key; otherwise the CDN answers 403 with 403 viewer/age-confirmation-required. Set it only where your app has already decided the viewer may see age-restricted content:
client.cdn.get_asset_url(asset_identifier=asset_id, lens="thumbnail", viewer_claims={"ageConfirmed": True})get_asset_url adds the claims to the URL as a JSON viewerClaims query parameter, and a signed URL includes the claims it was created with, so forward it unchanged. See Viewer claims and Age-restricted assets.
Webhooks
parse_webhook_event and verify_webhook_signature accept only the raw str or bytes body and raise TypeError for anything else. A body that was parsed and serialised again rarely has the same bytes (Python writes 1e-07 where the sender wrote 1e-7), so its signature would not match.
| Argument | Meaning |
|---|---|
headers | The request headers: a mapping, an email.message.Message or http.client.HTTPMessage, or a framework's header object. Names match in any case, and a header repeated in the request is joined with ,. |
signature, timestamp | The x-calmlens-signature and x-calmlens-timestamp values, in place of headers. |
secret | The connector's signing secret. |
tolerance_seconds | How far the timestamp may be from now: WEBHOOK_SIGNATURE_TOLERANCE_SECONDS (300) by default. |
now | The current time in Unix seconds, for tests; the system clock by default. |
A signature list may hold several comma-separated v1= entries, and any matching entry is accepted. parse_webhook_event returns a WebhookEventPayload dict, the event exactly as events.list returns it. It raises WebhookVerificationError with sdk/invalid-webhook-signature for a missing, wrong or stale signature, and with sdk/invalid-webhook-payload when the verified body is not a JSON object with string id, type and projectId, a numeric createdAt, and the API's types for its other known fields (each fact needs a string key and a value). Unknown fields, event types and fact keys are kept.
The header names are exported as constants:
| Constant | Header | Value |
|---|---|---|
WEBHOOK_SIGNATURE_HEADER | x-calmlens-signature | v1= and the hex HMAC-SHA256 of <timestamp>.<raw body>, possibly a comma-separated list. |
WEBHOOK_TIMESTAMP_HEADER | x-calmlens-timestamp | Unix seconds used in the signature. |
WEBHOOK_DELIVERY_HEADER | x-calmlens-delivery | The delivery ID, the same across a delivery's retries. |
To sign deliveries in your own tests, use create_webhook_headers(payload, delivery_id, secret, timestamp=None) or create_webhook_signature_header(payload, timestamp, secret):
from calmlens import create_webhook_headers, verify_webhook_signature
body = b'{"id":"evt_1","type":"asset.allowed"}'
headers = create_webhook_headers(body, "delivery-1", webhook_secret)
assert verify_webhook_signature(body, headers=headers, secret=webhook_secret)Moderation decisions arrive as asset.allowed, asset.limited, asset.age-restricted, asset.blocked and asset.purged events, and asset.classified reports each finished classification. The event's asset.classification.moderation.status holds the status. See Webhooks.
Events feed
events.list reads the project's event feed, the same events webhooks deliver. It returns an EventsPage dict, {"items": [...], "nextCursor": str, "hasMore": bool}. Each event has id, projectId, type, severity, title, description, assetId, asset, facts (a list of {key, value, url?}) and createdAt; description, assetId and asset are None when the event has none. to_webhook_event_payload(event) builds the webhook body for an event.
- Without
cursor, it returns no events and anextCursorfor now. Save that cursor: the feed returns only events created after the cursor you pass. - With the previous
nextCursorascursor, it returns the next events, oldest first, at mostpage_size(1 to 100, default 50).nextCursoris neverNone: with no new events, the same cursor comes back. hasMoreisTruewhen more events are already waiting. An invalid cursor raises 400 validation/invalid-cursor.
events.iter_pages(cursor=...) follows nextCursor until a page reports hasMore False, and events.iter(cursor=...) yields the events of those pages. To keep polling, save the last cursor and call them again:
import time
cursor = client.events.list()["nextCursor"] # start from now; no events
while True:
for page in client.events.iter_pages(cursor=cursor, page_size=50):
for event in page["items"]:
handle(event)
cursor = page["nextCursor"] # persist to resume after a restart
time.sleep(5)See Events for event types and facts.