calmlens 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
calmlens-0.1.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 CalmLens
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,533 @@
1
+ Metadata-Version: 2.4
2
+ Name: calmlens
3
+ Version: 0.1.1
4
+ Summary: Python SDK for CalmLens moderation, storage, and CDN delivery
5
+ Author: CalmLens
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://calmlens.com
8
+ Project-URL: Documentation, https://calmlens.com/docs/sdk/python
9
+ Project-URL: Repository, https://github.com/CalmLens/sdks/tree/main/python
10
+ Project-URL: Changelog, https://calmlens.com/docs/changelog
11
+ Keywords: calmlens,moderation,content-moderation,cdn,sdk
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Operating System :: OS Independent
22
+ Classifier: Topic :: Internet :: WWW/HTTP
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.10
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Dynamic: license-file
28
+
29
+ # CalmLens Python SDK
30
+
31
+ Server-side SDK for CalmLens moderation, storage and CDN delivery: every API operation,
32
+ streamed uploads, safe retries, webhook verification and an in-memory testing client.
33
+ Python 3.10+; no runtime dependencies; fully typed (`py.typed`, checked with `mypy --strict`).
34
+
35
+ ## Install
36
+
37
+ ```sh
38
+ python -m pip install calmlens
39
+ ```
40
+
41
+ ## Quick start
42
+
43
+ ```python
44
+ from calmlens import CalmLensServerClient
45
+
46
+ # Reads CALMLENS_API_KEY and CALMLENS_PROJECT_ID from the environment when not passed.
47
+ client = CalmLensServerClient()
48
+
49
+ asset = client.text.moderate(text="Great photo!", external_id="comment-42")
50
+ if asset["moderation"]["status"] == "approved":
51
+ print("Approved; policy action:", asset["moderation"]["action"])
52
+
53
+ with open("photo.jpg", "rb") as file: # streamed, not read into memory
54
+ queued = client.assets.create(name="photo.jpg", content={"data": file, "type": "image/jpeg"})
55
+ ```
56
+
57
+ Every method takes snake_case keyword arguments, or one options dict with the API's camelCase
58
+ keys (`client.assets.retrieve({"assetId": "..."})`); nested dicts accept either spelling.
59
+ Responses are plain dicts with the API's camelCase field names, typed as `TypedDict`s in
60
+ `calmlens.types`. Unknown fields from newer API versions stay in the dict, and enum-like
61
+ values (`moderation.status`, `processing.status`, `type`, `visibility`, ...) are plain strings, so
62
+ values this SDK version doesn't list never fail: treat an unknown status as "not approved".
63
+
64
+ ## Configuration
65
+
66
+ | Argument | Default | Meaning |
67
+ | --- | --- | --- |
68
+ | `api_key` | `CALMLENS_API_KEY` | Project API key |
69
+ | `project_id` | `CALMLENS_PROJECT_ID` | Project ID |
70
+ | `base_url`, `cdn_base_url` | `https://api.calmlens.com/v1`, `https://cdn.calmlens.com/v1` | Endpoints |
71
+ | `timeout` | `60` | Seconds a connection may stay silent before `sdk/timeout`. An inactivity limit, not a limit on the whole transfer, so large uploads and downloads are never cut off |
72
+ | `connect_timeout` | `10` | Seconds to open a connection (TCP, proxy and TLS) |
73
+ | `max_retries` | `2` | Retries for requests that are safe to resend (see [Retries](#retries)); `0` disables them |
74
+ | `transport` | pooled `http.client` | `transport(request, timeout)` returning a context-managed response with `status`, `headers` and `read()` |
75
+
76
+ Invalid options raise `CalmLensValidationError` with code `sdk/invalid-config` before the
77
+ client creates any state. `client.with_options(timeout=..., max_retries=...)` returns a client
78
+ that shares the connections and caches with different per-call settings, e.g.
79
+ `client.with_options(timeout=300).cdn.serve(...)`.
80
+
81
+ Reuse one client: it pools HTTP keep-alive connections and is **thread-safe**, so share it
82
+ between threads. Use `with CalmLensServerClient() as client:` or `client.close()` to close the pool;
83
+ custom transports remain caller-owned. After `os.fork()` (gunicorn, Celery, multiprocessing)
84
+ the child opens its own connections instead of sharing the parent's sockets. Redirects are
85
+ not followed, so the API key never leaves the configured endpoint. `repr(client)` never
86
+ includes the API key. Requests send `User-Agent: calmlens-python/<version> (<Python> <version>;
87
+ <platform>)`; the version is `calmlens.__version__`.
88
+
89
+ ### Async applications
90
+
91
+ Calls are synchronous. From `asyncio`, run them in a worker thread with one shared client:
92
+
93
+ ```python
94
+ asset = await asyncio.to_thread(client.text.moderate, text=body, external_id=comment_id)
95
+ ```
96
+
97
+ ### Proxies
98
+
99
+ The default transport follows `HTTP_PROXY`, `HTTPS_PROXY` and `NO_PROXY`, including
100
+ `user:password@` proxy credentials. HTTPS requests tunnel through the proxy with `CONNECT`, so
101
+ TLS still runs end to end to CalmLens. Point `HTTPS_PROXY` at the proxy's `http://` address:
102
+ an `https://` proxy URL (TLS to the proxy itself) is not supported for HTTPS requests and
103
+ raises `sdk/invalid-config`.
104
+
105
+ ## Errors
106
+
107
+ Every failure raises `CalmLensError` or a subclass, with a stable `code` (constants in
108
+ `calmlens.error_codes`, generated from the API's error codes):
109
+
110
+ | Exception | When | `code` | `status` |
111
+ | --- | --- | --- | --- |
112
+ | `CalmLensAPIError` | Non-2xx API response | The API code, e.g. `asset/not-found`; `sdk/http-error` without an error body | HTTP status |
113
+ | `CalmLensTimeoutError` | Connecting or waiting for data timed out | `sdk/timeout` | `None` |
114
+ | `CalmLensTransportError` | Any other network or protocol failure (refused, reset, DNS, TLS, truncated response) | `sdk/transport-error` | `None` |
115
+ | `CalmLensValidationError` | Invalid arguments or configuration, before anything is sent | `sdk/invalid-argument`, `sdk/invalid-config`, or the API's code for the same problem, e.g. `upload/invalid-payload`, `upload/invalid-mime-type` | `None` |
116
+ | `CalmLensError` | A 2xx response that isn't valid JSON, or a JSON response larger than 32 MiB | `sdk/invalid-response`, `sdk/response-too-large` | HTTP status |
117
+ | `CalmLensError` | `assets.wait_for_decision` saw no decision within its `timeout`; `details` has `assetId` and the last `processingStatus` and `moderationStatus` | `sdk/timeout` | `None` |
118
+
119
+ Each error also has `message`, `details` (the API's details, plus `traceId` when sent),
120
+ `request_id` (the response's `X-Request-Id`, to quote to support), `headers` (lower-case
121
+ names), `retry_after` (seconds from `Retry-After`), `is_not_found` and `is_retryable`.
122
+ Transport errors keep the native exception (`ConnectionResetError`, `ssl.SSLError`,
123
+ `http.client.IncompleteRead`, `TimeoutError`, ...) as `__cause__`. `CalmLensValidationError`
124
+ is also a `ValueError`.
125
+
126
+ ```python
127
+ from calmlens import CalmLensAPIError, error_codes
128
+
129
+ try:
130
+ asset = client.assets.retrieve(asset_id="asset-id")
131
+ except CalmLensAPIError as error:
132
+ if error.code != error_codes.ASSET_NOT_FOUND:
133
+ print(error.code, error.status, error.request_id)
134
+ raise
135
+ asset = None
136
+ ```
137
+
138
+ ### Retries
139
+
140
+ The client retries a request when resending it cannot duplicate work: `GET` requests, and
141
+ uploads (`create`, `moderate`, `text.moderate`, `upload_part`), which always carry an
142
+ `Idempotency-Key`. It retries network failures and timeouts, `429`, `502`, `503`, `504` and
143
+ `idempotency/request-in-progress`, up to `max_retries` times with jittered exponential
144
+ backoff (0.5s doubling, at most 8s), or after the `Retry-After` delay when the response sends
145
+ one (a `Retry-After` above 60 seconds raises the error instead). Other writes (`update`,
146
+ `move`, `retry`, folder changes, signed URLs) and CDN downloads are never retried.
147
+
148
+ Each upload call generates one `Idempotency-Key` and reuses it for its own retries; the API
149
+ replays the saved response for 24 hours instead of creating a second asset. Pass
150
+ `idempotency_key=` to retry across processes, e.g. a key derived from your own record ID.
151
+ Reusing a key for a different request raises `idempotency/key-reused`. A stream body is
152
+ rewound for a retry when it is seekable; uploads from non-seekable streams are not retried.
153
+
154
+ ## Operations
155
+
156
+ | JavaScript | Python |
157
+ | --- | --- |
158
+ | `assets.create/moderate/retrieve/update/list/search(options)` | Same names |
159
+ | `assets.listChildren(options)` | `assets.list_children(...)`; `assets.iter_children(...)` walks every page |
160
+ | (pagination) | `assets.iter(...)` yields every matching asset |
161
+ | `assets.uploadPart(options)` | `assets.upload_part(...)` |
162
+ | `assets.createSignedUrl(options)` | `assets.create_signed_url(...)` |
163
+ | `assets.move/retry/delete(options)` | Same names; `delete` returns `None` |
164
+ | `assets.deleteContent(options)` | `assets.delete_content(...)` |
165
+ | `assets.waitForDecision({ assetId, timeoutMs })` | `assets.wait_for_decision(asset_id=, timeout=60)`; `timeout` in seconds, no `signal` |
166
+ | `folders.list()` | `folders.list()` |
167
+ | `folders.create/retrieve/update/move/delete(options)` | Same names |
168
+ | `events.list(options)` | `events.list(...)`; `events.iter(...)` and `events.iter_pages(...)` (see [Events feed](#events-feed)) |
169
+ | `ensureLens/ensureLenses(options)` | `ensure_lens/ensure_lenses(...)` |
170
+ | `cdn.getAssetUrl/serve(options)` | `cdn.get_asset_url/serve(...)` |
171
+ | `verifyWebhookSignature(options)` | `verify_webhook_signature(payload, *, signature=, timestamp=, secret=, headers=, tolerance_seconds=300, now=None)` |
172
+ | `parseWebhookEvent(options)` | `parse_webhook_event(payload, *, ...)` (same keywords) |
173
+ | `createWebhookSignatureHeader` | `create_webhook_signature_header(payload, timestamp, secret)` |
174
+
175
+ ```python
176
+ page = client.assets.list(page_size=20, ordering=[{"field": "createdAt", "direction": "DESC"}])
177
+ for asset in client.assets.iter(filter="moderation.status==approved"): # requests pages lazily
178
+ print(asset["id"])
179
+ children = client.assets.list_children(asset_id=queued["id"])
180
+ client.assets.update(asset_id=queued["id"], description=None, tags=["reviewed"])
181
+ folder = client.folders.create(name="Photos")
182
+ client.assets.move(asset_id=queued["id"], folder_id=folder["id"])
183
+ client.assets.move(asset_id=queued["id"], folder_id=None) # project root
184
+ # Retrieve the asset until it is approved or rejected, or processing failed (sdk/timeout after 60
185
+ # seconds by default):
186
+ decided = client.assets.wait_for_decision(asset_id=queued["id"], timeout=30)
187
+ # For an asset whose processing failed and whose content is still held:
188
+ # client.assets.retry(asset_id=failed_asset_id)
189
+ # Remove stored content but keep the record and report (content.status becomes "removed"):
190
+ client.assets.delete_content(asset_id=queued["id"])
191
+ # Delete the record and its content:
192
+ client.assets.delete(asset_id=queued["id"])
193
+
194
+ client.ensure_lens(identifier="thumbnail", name="Thumbnail", type="image", active=True, version=1,
195
+ transforms=[{"type": "imageTransform", "options": {"width": 128}}])
196
+ signed = client.assets.create_signed_url(asset_id=asset_id, resource="preview", lens="thumbnail")
197
+ ```
198
+
199
+ `create` queues processing; `moderate` waits for classification. Text uses UTF-8 `text/plain`
200
+ content with an explicit asset name. List pagination starts at 0. `None` clears nullable
201
+ update fields and moves assets/folders to the root; omitted fields remain unchanged.
202
+ Signed URLs default to `content` and 900 seconds.
203
+
204
+ `wait_for_decision` checks again after 0.5 seconds, then waits 1.5 times longer each time, up
205
+ to 5 seconds between checks and never past `timeout`. Without a decision in time it raises
206
+ `CalmLensError` with code `sdk/timeout` and `details`
207
+ `{"assetId": ..., "processingStatus": ..., "moderationStatus": ...}`.
208
+ It has no cancellation argument: it blocks the calling thread for at most `timeout` seconds
209
+ (plus one request), so pass a shorter `timeout` to stop waiting sooner. Webhooks report
210
+ decisions without polling.
211
+
212
+ ### What `moderate` returns
213
+
214
+ `assets.moderate`, `text.moderate`, `assets.create` and the other asset methods return
215
+ the asset as a plain `dict` (typed as `calmlens.types.Asset`) with the API's
216
+ **camelCase** keys, e.g. `asset["externalId"]`, `asset["moderation"]["status"]`. `id`,
217
+ `projectId`, `name`, `type`, `createdAt` and three nested objects are always present:
218
+
219
+ - `processing`: `status` (`uploading` while content arrives, `processing`, then `done` or
220
+ `failed`), `updatedAt` and `uploadPartsReceived` for multipart uploads.
221
+ - `moderation`: `status` (`pending`, `approved` or `rejected`; a reviewer's decision takes
222
+ precedence over the automated one), `action` (what your policy did with flagged content:
223
+ `alert`, `hide` or `purge`, or `None` when nothing was flagged), `note` (why automated
224
+ moderation rejected the asset), `reviewerAction`, `reviewerNote`, `reviewerUserId`,
225
+ `reviewedAt`, `decidedAt` and `report`. `report` (the classification: `score`,
226
+ `categories`, `processedAt`, ...) is `None` until classification finishes. Inside it,
227
+ `categories` maps category names to `{status, score, likelihood, reason}`; a
228
+ `not-evaluated` category has no score.
229
+ - `content`: `status` (`temporary` while CalmLens holds the content only until processing
230
+ finishes, `stored` when `keepContentAfterProcessing` is on, or `removed`), `removalReason`
231
+ (`policy`, `retention`, `requested`, `parent-removed` or `disposed`), `updatedAt`,
232
+ `safetyHold`, `format` (MIME type), `extension`, `sizeInBytes`, `checksums`, `image`,
233
+ `previewImage` and `extractedText`. Fields that don't apply are `None`.
234
+
235
+ `description`, `externalId`, `externalUrl`, `folderId`, `parentId`, `metadata` and `tags`
236
+ may be missing or `None`, so use `.get()` for them.
237
+
238
+ Lens definitions are cached by full definition and version; repeated concurrent
239
+ ensures share the cached result. A changed version invalidates the previous cached
240
+ definition for that identifier. API version conflicts remain errors.
241
+
242
+ ## Uploads
243
+
244
+ Supply exactly one of `content`, `url` or `part`. A payload has exactly one of:
245
+
246
+ | Key | Value |
247
+ | --- | --- |
248
+ | `plainText` (`plain_text`) | A string, sent as UTF-8 |
249
+ | `base64` | Base64 or a data URL |
250
+ | `data` | `bytes`, `bytearray`, `memoryview`, or a binary file object (`open(path, "rb")`, `io.BytesIO`, a socket file, ...) |
251
+
252
+ File objects are streamed in chunks, never read into memory. The size is computed for bytes
253
+ and for seekable files (from the current position); for non-seekable streams (pipes,
254
+ sockets, generators wrapped in a reader) pass `sizeInBytes`, or the call raises
255
+ `upload/invalid-payload`. Exactly `sizeInBytes` bytes are sent; a stream that ends early
256
+ raises `upload/invalid-payload`. Text-mode files are rejected. Use a concrete MIME `type`
257
+ (e.g. `image/jpeg`) on the first request. Metadata and processing options accompany raw bytes
258
+ as query data; URL submissions use JSON.
259
+
260
+ ```python
261
+ upload = client.assets.create(
262
+ name="large.png",
263
+ total_size_in_bytes=12 * 1024 * 1024, # optional: checked before any part is stored
264
+ part={"type": "image/png", "data": first_part}, # at least 5 MiB
265
+ )
266
+ completed = client.assets.upload_part(
267
+ asset_id=upload["id"],
268
+ part={"data": final_part, "partNumber": 2, "isFinal": True},
269
+ )
270
+ ```
271
+
272
+ Parts are sequential. Non-final parts are at least 5 MiB. Multipart image totals must be
273
+ 10–20 MiB; direct images must be below 10 MiB. Split bytes before base64-encoding each part.
274
+ The API saves the upload's `create` (queued) or `moderate` (wait for the decision) choice when
275
+ it starts and applies it to the final part, from any client or process; pass `queue=` to
276
+ `upload_part` only to override it. With `total_size_in_bytes`, parts that don't add up to the
277
+ declared size raise `upload/size-mismatch`.
278
+
279
+ ## Upload presets
280
+
281
+ Pass `upload_preset_id` to `client.assets.create` or `client.assets.moderate` to apply an upload preset's folder, visibility, file types and size limit. To let a browser upload through a preset that requires tokens, sign a single-use token on your server. Signing happens locally with your API key and makes no request.
282
+
283
+ ```python
284
+ signed = client.upload_tokens.sign(
285
+ upload_preset_id="your-upload-preset-id",
286
+ external_id="post-42", # optional: the uploaded asset gets this ID
287
+ expires_in_seconds=600, # optional: 1 to 3600, 600 by default
288
+ )
289
+ # signed["token"], signed["expiresAt"] (milliseconds since the Unix epoch)
290
+ ```
291
+
292
+ See [Upload presets](https://calmlens.com/docs/guides/upload-presets). `MockCalmLensServerClient` signs tokens with its own key.
293
+
294
+ ## CDN
295
+
296
+ ```python
297
+ url = client.cdn.get_asset_url(asset_identifier=queued["id"], lens="thumbnail")
298
+ with client.cdn.serve(asset_identifier=queued["id"]) as response:
299
+ if response.ok:
300
+ with open("copy.jpg", "wb") as out:
301
+ for chunk in response.iter_bytes(): # streamed
302
+ out.write(chunk)
303
+ ```
304
+
305
+ Use either `lens` or `transform`. A `transform` is a selector string or one or more structured
306
+ steps (the shape of a lens's `transforms`); both forms also work for `assets.create_signed_url`:
307
+
308
+ ```python
309
+ client.cdn.get_asset_url(asset_identifier=queued["id"], transform="imageTransform[format=png,width=1024]")
310
+ client.cdn.get_asset_url(asset_identifier=queued["id"],
311
+ transform={"type": "imageTransform", "options": {"format": "png", "width": 1024}})
312
+ ```
313
+
314
+ Structured options are sorted and percent-encoded, so both calls return the same URL. `serve` returns a `CDNResponse` (`status`, `ok`, `headers`,
315
+ `content_type`, `request_id`, `read()`, `iter_bytes()`, `close()`) even for non-2xx responses;
316
+ check `response.ok` and close it. Use signed URLs for private CDN delivery in browsers.
317
+
318
+ ## Webhooks
319
+
320
+ Verify the **exact raw request body** before parsing it. Parsed JSON can't be verified: a
321
+ parsed and re-serialised body rarely has the same bytes (Python writes `1e-07` where the
322
+ sender wrote `1e-7`), so the helpers accept only `str` or `bytes` and raise `TypeError` for
323
+ anything else.
324
+
325
+ ```python
326
+ import os
327
+ from calmlens import WebhookVerificationError, parse_webhook_event
328
+
329
+ try:
330
+ event = parse_webhook_event(raw_body, headers=request_headers,
331
+ secret=os.environ["CALMLENS_WEBHOOK_SECRET"])
332
+ except WebhookVerificationError:
333
+ ... # respond 401
334
+ # event["type"], event["data"]["facts"]; deduplicate with the x-calmlens-delivery header
335
+ ```
336
+
337
+ `parse_webhook_event` verifies, then parses into a `WebhookEventPayload` dict
338
+ (`{id, type, createdAt, projectId, data}`, with `data` holding `severity`, `title`,
339
+ `description`, `assetId`, `asset` and `facts`). It raises `WebhookVerificationError` with code
340
+ `sdk/invalid-webhook-signature` or `sdk/invalid-webhook-payload`. `verify_webhook_signature` takes the
341
+ same keyword arguments and returns `True`/`False`. Pass `headers=` (any mapping; names are
342
+ matched case-insensitively), or `signature=` and `timestamp=` from the `x-calmlens-signature`
343
+ and `x-calmlens-timestamp` headers. Any matching `v1=` entry of a comma-separated signature
344
+ list is accepted; timestamps more than `tolerance_seconds` (default
345
+ `WEBHOOK_SIGNATURE_TOLERANCE_SECONDS`, 300) from `now` are rejected.
346
+
347
+ Getting the raw body in common frameworks:
348
+
349
+ ```python
350
+ # Flask
351
+ @app.post("/webhooks/calmlens")
352
+ def calmlens_webhook():
353
+ try:
354
+ event = parse_webhook_event(request.get_data(), headers=request.headers, secret=SECRET)
355
+ except WebhookVerificationError:
356
+ return "", 401
357
+ queue_work(event)
358
+ return "", 204
359
+
360
+ # Django (exempt the view from CSRF; request.body is the raw bytes)
361
+ @csrf_exempt
362
+ @require_POST
363
+ def calmlens_webhook(request):
364
+ try:
365
+ event = parse_webhook_event(request.body, headers=request.headers, secret=SECRET)
366
+ except WebhookVerificationError:
367
+ return HttpResponse(status=401)
368
+ queue_work(event)
369
+ return HttpResponse(status=204)
370
+
371
+ # FastAPI / Starlette (read the body yourself; don't declare a pydantic body model)
372
+ @app.post("/webhooks/calmlens", status_code=204)
373
+ async def calmlens_webhook(request: Request):
374
+ try:
375
+ event = parse_webhook_event(await request.body(), headers=request.headers, secret=SECRET)
376
+ except WebhookVerificationError:
377
+ raise HTTPException(status_code=401)
378
+ queue_work(event)
379
+ ```
380
+
381
+ Each delivery carries three headers, exported as constants:
382
+
383
+ | Constant | Header | Value |
384
+ | --- | --- | --- |
385
+ | `WEBHOOK_SIGNATURE_HEADER` | `x-calmlens-signature` | `v1=<hex>`, possibly a comma-separated list |
386
+ | `WEBHOOK_TIMESTAMP_HEADER` | `x-calmlens-timestamp` | Unix seconds used in the signature |
387
+ | `WEBHOOK_DELIVERY_HEADER` | `x-calmlens-delivery` | Unique delivery ID; use it to deduplicate retries |
388
+
389
+ `x-calmlens-signature` is `v1=` plus the hex HMAC-SHA256 of `"<x-calmlens-timestamp>.<raw
390
+ body>"`. To sign test deliveries, use `create_webhook_signature_header(payload, timestamp,
391
+ secret)` or `create_webhook_headers(payload, delivery_id, secret, *, timestamp=None)`:
392
+
393
+ ```python
394
+ from calmlens import create_webhook_headers, verify_webhook_signature
395
+ body = b'{"id":"evt_1","type":"asset.approved"}'
396
+ headers = create_webhook_headers(body, "delivery-1", webhook_secret)
397
+ assert verify_webhook_signature(body, headers=headers, secret=webhook_secret)
398
+ ```
399
+
400
+ For moderation decisions, check `asset.moderation.status` and `asset.moderation.action` (set when
401
+ content was flagged); a `not-evaluated`
402
+ category has no safe zero score and must not be treated as approved coverage.
403
+
404
+ ## Events feed
405
+
406
+ `events.list` polls the project's event feed, the same events webhooks deliver. It
407
+ returns an `EventsPage` dict: `{"events": [...], "cursor": str, "hasMore": bool}`. Each
408
+ event has `id`, `type` (e.g. `asset.approved`), `createdAt`, `severity`, `title`,
409
+ `description`, `assetId`, `asset` and `facts` (a list of `{key, value, url?}`).
410
+
411
+ - Calling it **without `after`** returns **no events** and a cursor for *now*. Save that
412
+ cursor: the feed only returns events created after the cursor you pass.
413
+ - Pass the previous `cursor` as `after` to get the next events (at most `limit`, default
414
+ 50, oldest first). When there are no new events, the same cursor is returned.
415
+ - `hasMore` is True when more events are already waiting. An invalid cursor raises
416
+ `CalmLensAPIError` with code `events/invalid-cursor`.
417
+
418
+ `events.iter_pages(after=...)` follows the cursor until a page reports `hasMore=False`, and
419
+ `events.iter(after=...)` yields the events of those pages:
420
+
421
+ ```python
422
+ import time
423
+
424
+ cursor = client.events.list()["cursor"] # start from now; no events
425
+ while True:
426
+ for page in client.events.iter_pages(after=cursor, limit=50):
427
+ for event in page["events"]:
428
+ handle(event)
429
+ cursor = page["cursor"] # persist to resume after a restart
430
+ time.sleep(5)
431
+ ```
432
+
433
+ ## Local webhook forwarding
434
+
435
+ Use the standalone [CalmLens CLI](https://calmlens.com/docs/guides/local-development) for local
436
+ development with any SDK. Install it separately through Homebrew, a platform download or npm.
437
+
438
+ ## Testing
439
+
440
+ ```python
441
+ from calmlens import CalmLensAPIError, CalmLensError, CalmLensTimeoutError
442
+ from calmlens.testing import MockCalmLensServerClient, create_mock_api_key
443
+
444
+ client = MockCalmLensServerClient(
445
+ api_key=create_mock_api_key(), project_id="project", timeout=5,
446
+ mock_options={"now": lambda: 123, "create_id": lambda: "asset-1"},
447
+ )
448
+ assert client.text.moderate(text="Hello", external_id="c-1")["moderation"]["status"] == "approved"
449
+ assert client.database_storage.assets["asset-1"]["createdAt"] == 123
450
+
451
+ request = client.requests[-1] # MockRequest(method, url, path, query, body, headers)
452
+ assert (request.method, request.path) == ("POST", "/projects/project/assets")
453
+ assert request.query["externalId"] == "c-1" and request.body == b"Hello"
454
+
455
+ client.enqueue_error(TimeoutError("timed out")) # or ConnectionResetError(), ssl.SSLError(), ...
456
+ try:
457
+ client.text.moderate(text="Hello")
458
+ except CalmLensTimeoutError as error:
459
+ assert error.code == "sdk/timeout" and isinstance(error.__cause__, TimeoutError)
460
+
461
+ client.enqueue_error(CalmLensError("rate-limit/exceeded", status=429, retry_after=5))
462
+ try:
463
+ client.assets.retrieve(asset_id="asset-1")
464
+ except CalmLensAPIError as error:
465
+ assert (error.status, error.retry_after) == (429, 5.0) and error.request_id
466
+
467
+ client.enqueue_event("asset.approved", asset_id="asset-1",
468
+ facts=[{"key": "decided-by", "value": "automated"}])
469
+ ```
470
+
471
+ `MockCalmLensServerClient` is the real client with an in-memory transport: every call goes through
472
+ the real client's validation, encoding, response handling, errors and retries, and the fake
473
+ API answers with the real status codes, JSON error bodies and headers (`X-Request-Id`,
474
+ `Retry-After`, `Idempotent-Replayed`). It accepts the real client's configuration (validated
475
+ the same way; `max_retries` defaults to 0 so enqueued failures surface), plus `mock_options`:
476
+ `database_storage`, `image_storage`, `create_id`, `now`, `queued_outcome`, `analyzed_outcome`
477
+ and `signed_url_base_url`. Unknown mock options raise.
478
+
479
+ - `queued_outcome` (default `pending`) applies to `assets.create` and queued multipart uploads;
480
+ `analyzed_outcome` (default `approved`) to `assets.moderate` and `text.moderate`. An outcome
481
+ is `pending` (processing `processing`, moderation `pending`), `approved` or `rejected`
482
+ (processing `done` with that decision) or `failed` (processing `failed`, retryable).
483
+ - Like the API, content is `stored` when `processingOptions.keepContentAfterProcessing` is
484
+ `True`; otherwise it is `temporary` and is removed (`removalReason` `retention`) once
485
+ processing is `done`, after which `cdn.serve` answers 404. Pass
486
+ `keepContentAfterProcessing: True` to serve content after a decision.
487
+
488
+ - `client.requests` returns copies of every captured request, oldest first, as
489
+ `MockRequest` objects: `method`, full `url`, `path` (relative to `base_url`, or to
490
+ `cdn_base_url` for `cdn.serve`), decoded `query` values, `body` (the JSON body as
491
+ Python objects, upload bytes, or `None`) and `headers` (left out of `repr`).
492
+ - `client.enqueue_error(error)` makes the next request fail and leaves storage unchanged.
493
+ A `CalmLensError` becomes an HTTP error response with its `status` (or the API's status for
494
+ its `code`, e.g. 404 for `asset/not-found`) and `retry_after`, raised as a
495
+ `CalmLensAPIError`. Network exceptions are raised like real failures (`TimeoutError` →
496
+ `sdk/timeout`, other `OSError`s and `http.client` errors → `sdk/transport-error`, with the
497
+ exception as `__cause__`).
498
+ - `client.enqueue_event(type, facts=..., asset_id=..., severity=..., title=...)` adds an event
499
+ to the feed; `events.list`, `events.iter` and cursors behave like the API's. The mock does
500
+ not create events by itself.
501
+ - `assets.wait_for_decision` reads the mock's `database_storage` and takes no real time: it
502
+ returns an asset that is already decided (or whose processing failed) and raises
503
+ `sdk/timeout` at once for one whose moderation stays `pending`. Set the stored asset's
504
+ `moderation.status` or `processing.status` to simulate a decision or failure.
505
+ - Idempotency keys are honoured: a repeated key replays the saved response, and a key reused
506
+ for a different request raises `idempotency/key-reused`.
507
+
508
+ The mock supports the same resource methods, folder constraints, lens version checks,
509
+ multipart uploads (saved queue mode, declared sizes, part order), stored content, content
510
+ and asset deletion, retries of failed assets, keyword search, list filters on top-level and nested
511
+ fields such as `moderation.status`, `processing.status` and `content.format` (a subset of RSQL: `==` with `*`, `!=`,
512
+ `=in=`, `=out=`, `=gt=`/`=ge=`/`=lt=`/`=le=`, `;` and `,`; other syntax raises
513
+ `NotImplementedError`) and ordering (newest first by default), and CDN download grants with
514
+ realistic response headers. It does not classify content, transform media or fetch external
515
+ URLs. Mock signatures and URLs are for tests only; inspect or seed the storage dictionaries
516
+ between calls. Keep `calmlens.testing` out of production imports.
517
+
518
+ ## Development and release
519
+
520
+ From the repository root (CI runs Python 3.10 and 3.14, and `mypy --strict`):
521
+
522
+ ```sh
523
+ PYTHONPATH=python python -m unittest discover -s python/tests -v
524
+ (cd python && python -m mypy)
525
+ python -m pip wheel --no-deps ./python --wheel-dir /tmp/calmlens-dist
526
+ ```
527
+
528
+ `calmlens/error_codes.py` is generated with the cross-language fixtures by
529
+ `bun packages/sdk-contract/generate.ts`. See [cross-language parity](https://calmlens.com/docs/sdk)
530
+ for the shared contract fixtures. The version lives only in `pyproject.toml`
531
+ (`calmlens.__version__` reads it). Publishing is manual: build with `python -m build` in this
532
+ package, inspect the wheel and sdist, then upload through the project's approved PyPI release
533
+ process.