keelson-sdk 0.1.0__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 STRICTUS LLC
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,385 @@
1
+ Metadata-Version: 2.4
2
+ Name: keelson-sdk
3
+ Version: 0.1.0
4
+ Summary: Keelson Python SDK
5
+ Author: Keelson
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://keelson.dev
8
+ Project-URL: Repository, https://github.com/keelsonhq/python-sdk
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Dynamic: license-file
19
+
20
+ # Keelson Python SDK
21
+
22
+ Python SDK for building apps on the Keelson platform. Provides four modules:
23
+
24
+ > **Note**: This repository is a read-only release mirror. Development happens in the private Keelson monorepo; issues are welcome here, but pull requests are not accepted — changes land through the next release.
25
+
26
+ | Module | Import | Description |
27
+ |--------|--------|-------------|
28
+ | `keelson_media` | `from keelson import media` | Media storage (upload, serve by ID) |
29
+ | `keelson_files` | `from keelson import files` | Data files (key-addressed, overwrite, private) |
30
+ | `keelson_identity` | `from keelson import identity` | User identity and directory |
31
+ | `keelson_email` | `from keelson import email` | Inbound/outbound email |
32
+
33
+ Cross-language parity across Node, Python, and Go is defined in
34
+ [`./PARITY.md`](./PARITY.md). APIs below are labelled as
35
+ **guaranteed** (same capability in all 3 languages) or
36
+ **Python-specific** (convenience helpers unique to this SDK).
37
+
38
+ ## Installation
39
+
40
+ ```bash
41
+ pip install keelson-sdk
42
+ ```
43
+
44
+ The PyPI distribution is named `keelson-sdk`; Python imports continue to use
45
+ `keelson` and the domain modules shown below.
46
+
47
+ ---
48
+
49
+ ## Media SDK (`keelson_media`)
50
+
51
+ Upload immutable media (images, PDFs, generated assets) and serve it by ID. On
52
+ Keelson it uses the managed media storage (internal media API); for local
53
+ development it uses the local filesystem. The runtime-mode contract is
54
+ **fail-closed**: it never silently writes to ephemeral local storage when platform
55
+ Media configuration is missing or incomplete (see Modes below).
56
+
57
+ ```python
58
+ from keelson import media
59
+
60
+ # Store a file — returns a generated ULID file ID
61
+ file_id = media.put(b"Hello, world!", content_type="text/plain")
62
+
63
+ # Store with filename (MIME type auto-detected)
64
+ with open("report.pdf", "rb") as f:
65
+ pdf_id = media.put(f.read(), filename="report.pdf")
66
+
67
+ # Retrieve
68
+ data = media.get(file_id)
69
+
70
+ # Check existence and metadata
71
+ if media.exists(file_id):
72
+ info = media.stat(file_id)
73
+ print(info.content_type, info.content_length)
74
+
75
+ # Public URL (for embedding in HTML)
76
+ src = media.url(file_id) # "/media/01HXYZ..."
77
+
78
+ # Delete
79
+ media.delete(file_id)
80
+ ```
81
+
82
+ ### Cross-language guaranteed API
83
+
84
+ | Function | Signature | Description |
85
+ |----------|-----------|-------------|
86
+ | `put` | `(data, *, content_type=None, filename=None) -> str` | Store file, returns ULID `file_id` |
87
+ | `get` | `(file_id) -> bytes` | Download file content |
88
+ | `delete` | `(file_id) -> None` | Delete file |
89
+ | `exists` | `(file_id) -> bool` | Check if file exists |
90
+ | `stat` | `(file_id) -> MediaStat` | Get metadata (content_type, content_length, status) |
91
+ | `url` | `(file_id) -> str` | Generate public URL path |
92
+
93
+ ### Python-specific helpers
94
+
95
+ | Function | Signature | Description |
96
+ |----------|-----------|-------------|
97
+ | `open` | `(file_id) -> BinaryIO` | Get file as binary stream (wraps `get()` in `BytesIO`) |
98
+
99
+ **Data class**: `MediaStat` (frozen dataclass with `content_type: str`, `content_length: int`, `status: int`).
100
+
101
+ **Exception**: `MediaError` (subclass of `RuntimeError`).
102
+
103
+ ### Modes (fail-closed runtime-mode contract)
104
+
105
+ | `KEELSON_MODE` | Condition | Behaviour |
106
+ |------|-----------|-----------|
107
+ | `keelson` | Both Media env set | Remote (the Keelson media service) |
108
+ | `keelson` | Media env missing | **`MediaError`** — capability unavailable (covers `files_enabled=false`); never local |
109
+ | any | Exactly one of base URL / token set | **`MediaError`** — incomplete remote config |
110
+ | `local` | — | Local filesystem (`MEDIA_DIR`, default `./media`) |
111
+ | unset | Both Media env set | Remote (backward compatibility) |
112
+ | unset | No Media env, platform core env visible (`KEELSON_APP_ID` / `KEELSON_TENANT_ID` / `KEELSON_DEPLOY_ID`) | **`MediaError`** — refuses silent local fallback |
113
+ | unset | No Media env, no platform env | Local filesystem (local development) |
114
+
115
+ The SDK never silently falls back to ephemeral local storage on Keelson: set
116
+ `KEELSON_MODE=local` explicitly for local development.
117
+
118
+ ### Environment variables
119
+
120
+ | Variable | Description |
121
+ |----------|-------------|
122
+ | `KEELSON_MODE` | `keelson` (remote, fail-closed) / `local` (local FS) / unset (local development). Platform injects `keelson`. |
123
+ | `KEELSON_INTERNAL_MEDIA_BASE_URL` | Internal endpoint for the Keelson media service (Keelson mode; required with the token). |
124
+ | `KEELSON_APP_MEDIA_TOKEN` | App-scoped bearer token for the Keelson media service; validated by the platform. |
125
+ | `KEELSON_MEDIA_URL_PREFIX` | Public URL prefix (default: `/media/`). |
126
+ | `MEDIA_DIR` | Local storage directory (default: `./media`). Used whenever the SDK resolves to local mode — either explicit `KEELSON_MODE=local`, or zero-config local development (`KEELSON_MODE` unset with no Media env and no platform core env). |
127
+
128
+ ---
129
+
130
+ ## Files (data) SDK (`keelson_files`)
131
+
132
+ Durable file storage for your app's own files — state, settings, caches. Reads
133
+ and writes are always whole-file, and `write()` is write-through: once it
134
+ returns, the data is persisted. There is no background sync and nothing is
135
+ stored on ephemeral local disk. Overwriting an existing key is the normal case;
136
+ updates to the same key are limited to about once per second. For user-uploaded
137
+ or generated media referenced by ID and served over HTTP, use `keelson_media`;
138
+ for data read/written on every request, use the database.
139
+
140
+ ```python
141
+ from keelson import files
142
+
143
+ files.write("seen_urls.json", json.dumps(seen))
144
+ seen = json.loads(files.read("seen_urls.json") or "[]") # read() -> bytes | None
145
+ keys = files.list() # sorted list[str]
146
+ files.delete("seen_urls.json") # idempotent
147
+ ```
148
+
149
+ ### Cross-language guaranteed API
150
+
151
+ | Function | Description |
152
+ |----------|-------------|
153
+ | `write(key, data)` | Overwrite `key` with bytes/str (str stored UTF-8); write-through |
154
+ | `read(key)` | `bytes`, or `None` when the key is absent (only a 404 is missing) |
155
+ | `delete(key)` | Idempotent delete |
156
+ | `list(prefix="")` | Full, lexicographically-sorted key list; paging absorbed |
157
+
158
+ Key grammar: `/`-separated relative path, well-formed UTF-8 ≤ 512 bytes total and
159
+ ≤ 255 bytes per segment, no leading/trailing `/`, no empty / `.` / `..` segments,
160
+ no control characters. One-object soft limit 10 MiB. There is no `exists()` —
161
+ `read()` returning `None` covers it.
162
+
163
+ ### Environment variables
164
+
165
+ | Variable | Description |
166
+ |----------|-------------|
167
+ | `KEELSON_MODE` | `keelson` (remote) or `local`; the single mode signal |
168
+ | `KEELSON_FILES_BUCKET` / `KEELSON_FILES_PREFIX` | Platform-injected in `keelson` mode (managed object storage) |
169
+ | `KEELSON_FILES_DIR` | Local-mode directory (default `./.keelson/files`) |
170
+
171
+ Fail-closed: `KEELSON_MODE=keelson` requires bucket + prefix + platform identity;
172
+ missing config raises `FilesError`. See [`./PARITY.md`](./PARITY.md) for the
173
+ full contract.
174
+
175
+ ---
176
+
177
+ ## Identity SDK (`keelson_identity`)
178
+
179
+ User identity and tenant directory lookup. In production, the Keelson auth
180
+ gateway injects trusted `X-Keelson-User-*` headers before requests reach the app.
181
+ Use `get_current_user` when the basic user profile is enough; use
182
+ `get_current_identity` when the app needs tenant role, app permissions, app
183
+ roles, or group attributes.
184
+
185
+ ```python
186
+ import os
187
+
188
+ from keelson_identity import (
189
+ get_current_user,
190
+ get_current_identity,
191
+ list_members,
192
+ get_user,
193
+ list_groups,
194
+ )
195
+
196
+ user = get_current_user(headers=request.headers)
197
+ print(user.email)
198
+
199
+ identity = get_current_identity(
200
+ headers=request.headers,
201
+ app_token=os.environ["KEELSON_DIRECTORY_TOKEN"],
202
+ )
203
+ print(identity.tenant.role)
204
+ print(identity.app.permissions) # ["manage", "view"]
205
+ if identity.attributes:
206
+ print(identity.attributes.groups) # ["developers", "everyone"]
207
+
208
+ # Directory lookup as the app actor
209
+ page = list_members(
210
+ app_token=os.environ["KEELSON_DIRECTORY_TOKEN"],
211
+ limit=25, offset=0, q="alice",
212
+ )
213
+ for member in page.items:
214
+ print(member.id, member.email, member.name)
215
+
216
+ member = get_user("user-id-here", app_token=os.environ["KEELSON_DIRECTORY_TOKEN"])
217
+ groups = list_groups(app_token=os.environ["KEELSON_DIRECTORY_TOKEN"])
218
+ ```
219
+
220
+ ### Cross-language guaranteed API
221
+
222
+ | Function | Signature | Description |
223
+ |----------|-----------|-------------|
224
+ | `get_current_user` | `(headers=...) -> UserIdentity` | Parse the current user's basic profile from trusted `X-Keelson-User-*` headers; no network call |
225
+ | `get_current_identity` | `(headers=..., app_token=...) -> CurrentIdentity` | Fetch the current user's full identity as the app actor |
226
+ | `list_members` | `(*, base_url=None, cookie=None, authorization=None, app_token=None, **filters) -> PaginatedMembers` | List tenant members |
227
+ | `get_user` | `(user_id, *, base_url=None, cookie=None, authorization=None, app_token=None) -> MemberItem` | Get user by ID |
228
+ | `list_groups` | `(*, base_url=None, cookie=None, authorization=None, app_token=None) -> list[GroupItem]` | List tenant groups |
229
+
230
+ `get_current_user` and `get_current_identity` accept common request header
231
+ mappings. The required header is `x-keelson-user-id`; `x-keelson-user-email`
232
+ and `x-keelson-user-name` are optional.
233
+
234
+ Directory functions also support app-as-actor access with `app_token` or the
235
+ `KEELSON_DIRECTORY_TOKEN` env fallback. Keep app tokens on the server.
236
+
237
+ ### Python-specific helpers
238
+
239
+ | Function | Signature | Description |
240
+ |----------|-----------|-------------|
241
+ | `is_local_mode` | `() -> bool` | Check if running in local mode |
242
+
243
+ **Data classes**: `CurrentIdentity`, `UserIdentity`, `TenantIdentity`, `AppIdentity`, `AttributesIdentity`, `MemberItem`, `PaginatedMembers`, `GroupItem`.
244
+
245
+ **Exception**: `IdentityError`.
246
+
247
+ ### Filtering members by group
248
+
249
+ `list_members` narrows results to a single group via either `group_id` or
250
+ `group_key`:
251
+
252
+ - `group_key` — the group's code-facing identifier. Always present, stable, and
253
+ immutable. **Prefer this for code references.** Keys may be non-ASCII (e.g. a
254
+ Japanese `経理`).
255
+ - `group_id` — a stable UUID for machine integration / internal wiring.
256
+
257
+ Passing both raises `IdentityError`. On `GroupItem`, `key` is `str | None`
258
+ kept nullable for backward compatibility, but the server always populates it;
259
+ `id` is the UUID.
260
+
261
+ ### `attributes.groups` vs `list_groups()`
262
+
263
+ - **`attributes.groups`** (from `get_current_identity()`): the group keys the *current user* belongs to, **scoped to the current app** — the system groups the caller holds by role (a subset of `owners`/`admins`/`developers`/`everyone`, not all four: an OWNER gets `owners`/`developers`/`everyone`, an app user gets only `everyone`; exposed regardless of app binding) plus custom groups bound to this app (via a view/manage permission binding or an app-role binding). Custom groups not bound to the app are excluded, and a system group the caller does not hold never appears. Keys are stable and immutable, so they are safe for authorization checks; bind a group to the app if you need to branch on it.
264
+ - **`list_groups()`**: all groups in the *tenant* (each with its stable `id` and `key`). Use for building UI pickers and admin views.
265
+
266
+ ### Modes
267
+
268
+ | Mode | Condition | Behaviour |
269
+ |------|-----------|-----------|
270
+ | Local | `KEELSON_LOCAL_MODE=1` | Returns deterministic fixture data (no HTTP calls) |
271
+ | Keelson | Default | Calls the Keelson auth gateway via the platform-injected `KEELSON_DIRECTORY_BASE_URL`. |
272
+
273
+ ### Environment variables
274
+
275
+ | Variable | Description |
276
+ |----------|-------------|
277
+ | `KEELSON_LOCAL_MODE` | Set to `1` to enable local mode (returns fixture data, no HTTP calls). |
278
+ | `KEELSON_DIRECTORY_BASE_URL` | **Canonical, platform-injected** base URL for `get_current_identity` and Directory calls. Use this. |
279
+ | `KEELSON_DIRECTORY_TOKEN` | App token for app-as-actor identity and Directory access. |
280
+ | `KEELSON_IDENTITY_BASE_URL` | Deprecated compatibility alias used only when neither `base_url` nor `KEELSON_DIRECTORY_BASE_URL` is set. |
281
+
282
+ ---
283
+
284
+ ## Email SDK (`keelson_email`)
285
+
286
+ Inbound/outbound email.
287
+
288
+ ```python
289
+ from keelson import email
290
+
291
+ # Send
292
+ email.send(
293
+ to="user@example.com",
294
+ subject="Hello",
295
+ text="Plain text body",
296
+ html="<p>HTML body</p>",
297
+ )
298
+
299
+ # Receive inbound emails via decorator
300
+ @email.on_receive
301
+ def handle(msg: email.InboundMessage):
302
+ print(msg.subject, msg.text)
303
+ email.send(
304
+ to=msg.reply_to or msg.from_,
305
+ subject=f"Re: {msg.subject}",
306
+ text="Got it!",
307
+ in_reply_to=msg.provider_message_id,
308
+ )
309
+
310
+ # Handle bounce/complaint events
311
+ @email.on_event
312
+ def handle_event(event: email.EmailEventPayload):
313
+ if event.event_type == "bounce":
314
+ print("Bounced:", event.email_address)
315
+ ```
316
+
317
+ ### Cross-language guaranteed API
318
+
319
+ | Function | Signature | Description |
320
+ |----------|-----------|-------------|
321
+ | `send` | `(to, subject, text=None, html=None, **kwargs)` | Send an email |
322
+ | `verify_webhook` | `(body, headers, secret) -> InboundMessage` | Verify Svix signature and parse inbound email |
323
+ | `verify_webhook_bytes` | `(body, headers, secret) -> InboundMessage` | Verify inbound email from raw bytes |
324
+ | `verify_event_webhook` | `(body, headers, secret) -> EmailEventPayload` | Verify Svix signature and parse event |
325
+ | `verify_event_webhook_bytes` | `(body, headers, secret) -> EmailEventPayload` | Verify event from raw bytes |
326
+
327
+ Attachment download is also guaranteed; in Python it is an instance method on `InboundAttachment`:
328
+
329
+ ```python
330
+ for att in msg.attachments:
331
+ data = att.download() # -> bytes
332
+ ```
333
+
334
+ ### Python-specific helpers
335
+
336
+ | Function | Signature | Description |
337
+ |----------|-----------|-------------|
338
+ | `on_receive` | `(handler)` | Decorator to handle inbound email (auto-starts webhook server) |
339
+ | `on_event` | `(handler)` | Decorator to handle bounce/complaint/delivery events |
340
+ | `serve` | `(**kwargs)` | Start webhook server manually |
341
+
342
+ When `KEELSON_EMAIL_WEBHOOK_SECRET` is set, `serve()` and the decorators automatically verify inbound webhooks.
343
+
344
+ **Data classes**: `Address`, `Attachment`, `InboundMessage`, `InboundAttachment`, `EmailEventPayload`, `SpamAssessment`, `AuthenticationResult`.
345
+
346
+ **Exception**: `EmailError`.
347
+
348
+ ### Environment variables
349
+
350
+ | Variable | Description |
351
+ |----------|-------------|
352
+ | `KEELSON_EMAIL_API_URL` | Email API endpoint (required). |
353
+ | `KEELSON_EMAIL_TOKEN` | Bearer token (required). |
354
+ | `KEELSON_EMAIL_WEBHOOK_SECRET` | Optional Svix signing secret for auto-verification. |
355
+
356
+ ---
357
+
358
+ ## Testing
359
+
360
+ Run all SDK tests:
361
+
362
+ ```bash
363
+ uv sync --group dev
364
+ uv run pytest
365
+ ```
366
+
367
+ Build the source distribution and wheel from the repository root:
368
+
369
+ ```bash
370
+ uv build
371
+ ```
372
+
373
+ Run tests for individual modules:
374
+
375
+ ```bash
376
+ uv run pytest keelson_media/tests/
377
+ uv run pytest keelson_identity/tests/
378
+ uv run pytest keelson_email/tests/
379
+ ```
380
+
381
+ Lint:
382
+
383
+ ```bash
384
+ uv run ruff check .
385
+ ```