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 +21 -0
- calmlens-0.1.1/PKG-INFO +533 -0
- calmlens-0.1.1/README.md +505 -0
- calmlens-0.1.1/calmlens/__init__.py +71 -0
- calmlens-0.1.1/calmlens/_version.py +23 -0
- calmlens-0.1.1/calmlens/client.py +1364 -0
- calmlens-0.1.1/calmlens/error_codes.py +137 -0
- calmlens-0.1.1/calmlens/errors.py +123 -0
- calmlens-0.1.1/calmlens/py.typed +0 -0
- calmlens-0.1.1/calmlens/testing.py +763 -0
- calmlens-0.1.1/calmlens/types.py +906 -0
- calmlens-0.1.1/calmlens/webhooks.py +167 -0
- calmlens-0.1.1/calmlens.egg-info/PKG-INFO +533 -0
- calmlens-0.1.1/calmlens.egg-info/SOURCES.txt +22 -0
- calmlens-0.1.1/calmlens.egg-info/dependency_links.txt +1 -0
- calmlens-0.1.1/calmlens.egg-info/top_level.txt +1 -0
- calmlens-0.1.1/pyproject.toml +45 -0
- calmlens-0.1.1/setup.cfg +4 -0
- calmlens-0.1.1/tests/test_client.py +497 -0
- calmlens-0.1.1/tests/test_connections.py +152 -0
- calmlens-0.1.1/tests/test_contract.py +58 -0
- calmlens-0.1.1/tests/test_testing.py +373 -0
- calmlens-0.1.1/tests/test_transport.py +285 -0
- calmlens-0.1.1/tests/test_webhooks.py +135 -0
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.
|
calmlens-0.1.1/PKG-INFO
ADDED
|
@@ -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.
|