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:

ArgumentDefaultMeaning
api_keyCALMLENS_API_KEYProject API key.
project_idCALMLENS_PROJECT_IDProject ID.
base_urlhttps://api.calmlens.com/v1API endpoint.
cdn_base_urlhttps://cdn.calmlens.com/v1CDN endpoint.
timeout120Seconds 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_timeoutNoneThe timeout of binary uploads (file content and multipart parts); None uses timeout. Text uploads always use timeout.
connect_timeout10Seconds to open a connection, including the proxy CONNECT and the TLS handshake.
max_retries2Retries for requests that are safe to resend (see Retries and idempotency); 0 disables them.
max_response_bytes33554432 (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.
transportPooled http.client connectionsA 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.

ExceptionRaised whencodestatus
CalmLensAPIErrorThe 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
CalmLensTimeoutErrorConnecting or waiting for data timed out.sdk/timeoutNone
CalmLensTransportErrorAny other network or protocol failure: refused, reset, DNS, TLS or a truncated response.sdk/transport-errorNone
CalmLensValidationErrorArguments 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
CalmLensErrorA 2xx response isn't the JSON the operation returns, or is larger than max_response_bytes.sdk/invalid-response, sdk/response-too-largeHTTP status
CalmLensErrorAn iterator received a page that says more follow, with a cursor it had already sent; details["cursor"] holds it.sdk/invalid-responseNone
CalmLensErrorassets.wait_for_decision saw no decision within its timeout.sdk/timeoutNone
WebhookVerificationErrorA webhook delivery fails verification or isn't a CalmLens event.sdk/invalid-webhook-signature, sdk/invalid-webhook-payloadNone

Each error also has:

AttributeValue
messageA readable description.
detailsThe API error body's details object, such as validation issues; empty for transport errors.
request_idThe response's X-Request-Id, or the error body's requestId without one. Quote it to support.
headersThe response headers, with lower-case names.
retry_afterWhole seconds from Retry-After, sent in seconds or as an HTTP date.
is_not_foundWhether the resource doesn't exist.
is_retryableDepends 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 needs confirm_purge=True.
  • assets.explain(asset_id=..., rerun=True) moderates the asset again under the current policy; without rerun it returns the policy evidence.
  • assets.batch takes 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 the rendered resource for 900 seconds unless you pass resource and expires_in_seconds (1 to 86,400). It takes the same lens, transform and viewer_claims as 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:

KeyValue
plainText (plain_text)A string, sent as UTF-8.
base64Base64 or a data URL.
databytes, 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.

ArgumentMeaning
headersThe 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, timestampThe x-calmlens-signature and x-calmlens-timestamp values, in place of headers.
secretThe connector's signing secret.
tolerance_secondsHow far the timestamp may be from now: WEBHOOK_SIGNATURE_TOLERANCE_SECONDS (300) by default.
nowThe 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:

ConstantHeaderValue
WEBHOOK_SIGNATURE_HEADERx-calmlens-signaturev1= and the hex HMAC-SHA256 of <timestamp>.<raw body>, possibly a comma-separated list.
WEBHOOK_TIMESTAMP_HEADERx-calmlens-timestampUnix seconds used in the signature.
WEBHOOK_DELIVERY_HEADERx-calmlens-deliveryThe 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 a nextCursor for now. Save that cursor: the feed returns only events created after the cursor you pass.
  • With the previous nextCursor as cursor, it returns the next events, oldest first, at most page_size (1 to 100, default 50). nextCursor is never None: with no new events, the same cursor comes back.
  • hasMore is True when 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.

Last updated

Signing in

Signing out

Cookies on CalmLens

We use essential cookies for sign-in and security. With your permission, functional cookies remember your preferences and Google Analytics measures website use and signups. Optional ad measurement cookies link Google ad clicks to signups. Personalised advertising stays off. Manage cookies