herald-run 0.3.0b1__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,9 @@
1
+ node_modules/
2
+ dist/
3
+ dist-python/
4
+ dist-npm/
5
+ .venv/
6
+ __pycache__/
7
+ *.pyc
8
+ .env*
9
+ .DS_Store
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Herald contributors
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,52 @@
1
+ Metadata-Version: 2.5
2
+ Name: herald-run
3
+ Version: 0.3.0b1
4
+ Summary: Python client for Herald, the human-in-the-loop inbox for agents
5
+ Project-URL: Homepage, https://herald.run
6
+ Project-URL: Documentation, https://herald.run/docs/python/
7
+ Project-URL: Source, https://github.com/herald-run/sdk
8
+ Project-URL: Issues, https://github.com/herald-run/sdk/issues
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.11
15
+ Requires-Dist: cryptography>=42
16
+ Requires-Dist: httpx<1,>=0.27
17
+ Requires-Dist: pydantic<3,>=2.6
18
+ Requires-Dist: websockets<17,>=14
19
+ Description-Content-Type: text/markdown
20
+
21
+ # herald-run
22
+
23
+ Python client for [Herald](https://herald.run), the human-in-the-loop inbox for agents: send messages, ask for approvals, run tasks, and move files and secrets. Python 3.11+. The import name is `herald`.
24
+
25
+ ```sh
26
+ pip install --pre herald-run
27
+ ```
28
+
29
+ ## Authentication
30
+
31
+ Create a token in Herald under Settings → Connections, or run `npx @herald-run/cli login`. Pass it as `api_key`, or set `HERALD_API_KEY` (or `HERALD_TOKEN`). `HERALD_URL` selects another server. Never commit tokens.
32
+
33
+ ## One-minute example
34
+
35
+ ```python
36
+ import asyncio
37
+ from herald import Herald
38
+
39
+ async def main():
40
+ async with Herald() as herald:
41
+ await herald.send(title="Build passed", body="Preview is ready.")
42
+ answer = await herald.ask_action(
43
+ title="Deploy to production?",
44
+ actions=[{"id": "approve", "label": "Approve", "values": {}}, {"id": "reject", "label": "Reject", "values": {}}],
45
+ timeout=600,
46
+ )
47
+ print(answer.action_id)
48
+
49
+ asyncio.run(main())
50
+ ```
51
+
52
+ Without asyncio, use `SyncHerald`, which has the same methods: `SyncHerald().send(title="Hi")`. Failures raise `HeraldError` with `status`, `code`, `message_id` and `retry_after`. Methods are snake_case and return Pydantic models (`.wire()` gives the JSON). Documentation: [herald.run/docs/python](https://herald.run/docs/python/); API reference: [herald.run/docs/api](https://herald.run/docs/api/). MIT licensed.
@@ -0,0 +1,32 @@
1
+ # herald-run
2
+
3
+ Python client for [Herald](https://herald.run), the human-in-the-loop inbox for agents: send messages, ask for approvals, run tasks, and move files and secrets. Python 3.11+. The import name is `herald`.
4
+
5
+ ```sh
6
+ pip install --pre herald-run
7
+ ```
8
+
9
+ ## Authentication
10
+
11
+ Create a token in Herald under Settings → Connections, or run `npx @herald-run/cli login`. Pass it as `api_key`, or set `HERALD_API_KEY` (or `HERALD_TOKEN`). `HERALD_URL` selects another server. Never commit tokens.
12
+
13
+ ## One-minute example
14
+
15
+ ```python
16
+ import asyncio
17
+ from herald import Herald
18
+
19
+ async def main():
20
+ async with Herald() as herald:
21
+ await herald.send(title="Build passed", body="Preview is ready.")
22
+ answer = await herald.ask_action(
23
+ title="Deploy to production?",
24
+ actions=[{"id": "approve", "label": "Approve", "values": {}}, {"id": "reject", "label": "Reject", "values": {}}],
25
+ timeout=600,
26
+ )
27
+ print(answer.action_id)
28
+
29
+ asyncio.run(main())
30
+ ```
31
+
32
+ Without asyncio, use `SyncHerald`, which has the same methods: `SyncHerald().send(title="Hi")`. Failures raise `HeraldError` with `status`, `code`, `message_id` and `retry_after`. Methods are snake_case and return Pydantic models (`.wire()` gives the JSON). Documentation: [herald.run/docs/python](https://herald.run/docs/python/); API reference: [herald.run/docs/api](https://herald.run/docs/api/). MIT licensed.
@@ -0,0 +1,11 @@
1
+ """Herald's async client (`Herald`) and its blocking wrapper (`SyncHerald`). Runtime checkpoints remain with the caller."""
2
+ from ._version import __version__
3
+ from .client import Herald
4
+ from .crypto import create_keys, seal, unseal
5
+ from .http import HeraldError
6
+ from .live import LiveSocket, backoff
7
+ from .models import Account, ActionAnswer, Attachment, CreatedIntegration, Destination, Envelope, FileDownload, Integration, Lease, Message, Ping, Response, SecretReceiver, TaskClaim, TaskExecution, TaskInput, TaskLog, TaskResult, ThreadPage, TokenInfo, Tombstone
8
+ from .sync import SyncHerald
9
+ from .webhook import verify_webhook
10
+
11
+ __all__ = ["Account", "ActionAnswer", "Attachment", "CreatedIntegration", "Destination", "Envelope", "FileDownload", "Herald", "HeraldError", "Integration", "Lease", "LiveSocket", "Message", "Ping", "Response", "SecretReceiver", "SyncHerald", "TaskClaim", "TaskExecution", "TaskInput", "TaskLog", "TaskResult", "ThreadPage", "TokenInfo", "Tombstone", "__version__", "backoff", "create_keys", "seal", "unseal", "verify_webhook"]
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0b1"
@@ -0,0 +1,336 @@
1
+ import asyncio
2
+ import os
3
+ import re
4
+ import uuid
5
+ from urllib.parse import quote, unquote, urlencode, urlparse
6
+
7
+ import httpx
8
+ from pydantic import BaseModel
9
+ from websockets.asyncio.client import connect
10
+
11
+ from ._version import __version__
12
+ from .crypto import unseal
13
+ from .http import HeraldError, chunks, idle_body, parse_retry_after, raise_error, read_json, retry_delay, retryable
14
+ from .live import LiveSocket
15
+ from .models import Account, ActionAnswer, Attachment, CreatedIntegration, Destination, Envelope, FileDownload, Integration, Lease, Message, Ping, SecretReceiver, TaskClaim, TaskLog, ThreadPage, TokenInfo, Tombstone
16
+
17
+ RETRIES = 2
18
+ # Seconds between HTTP checks while the live socket is quiet or down.
19
+ POLL = 30
20
+ FINAL = ("answered", "expired", "cancelled")
21
+
22
+
23
+ class OriginConnection(connect):
24
+ # Some supported websockets versions resend additional_headers on redirect.
25
+ # Herald event streams must stay on the configured origin on every version.
26
+ def process_redirect(self, error):
27
+ return error
28
+
29
+
30
+ def path(value):
31
+ return quote(value, safe="")
32
+
33
+
34
+ def file_name(header):
35
+ """The file name from `Content-Disposition`, without any directory part."""
36
+ encoded = re.search(r"filename\*=UTF-8''([^;]+)", header or "", re.I)
37
+ plain = re.search(r'filename="([^"]*)"', header or "", re.I)
38
+ name = unquote(encoded[1]) if encoded else plain[1] if plain else ""
39
+ return re.split(r"[\\/]", name)[-1]
40
+
41
+
42
+ def wire(value):
43
+ """Request JSON: models nested anywhere become their camelCase records."""
44
+ if isinstance(value, BaseModel):
45
+ return value.wire()
46
+ if isinstance(value, dict):
47
+ return {key: wire(item) for key, item in value.items()}
48
+ if isinstance(value, (list, tuple)):
49
+ return [wire(item) for item in value]
50
+ return value
51
+
52
+
53
+ class Herald:
54
+ def __init__(self, api_key=None, url=None, *, max_retry_after=60, transport=None, user_agent=None):
55
+ self.url = (url or os.getenv("HERALD_URL", "https://app.herald.run")).rstrip("/")
56
+ origin = urlparse(self.url)
57
+ if origin.scheme != "https" and not (origin.scheme == "http" and origin.hostname in ("localhost", "127.0.0.1")):
58
+ raise ValueError("Use HTTPS for Herald.")
59
+ if origin.username or origin.password or origin.query or origin.fragment or origin.path not in ("", "/"):
60
+ raise ValueError("Use a server URL without credentials, path, query or fragment.")
61
+ self.key = api_key or os.getenv("HERALD_API_KEY") or os.getenv("HERALD_TOKEN", "")
62
+ # Longest server Retry-After, in seconds, that a request waits out before failing.
63
+ self.max_retry_after = max_retry_after
64
+ # `transport` replaces the network, e.g. with `httpx.MockTransport` in tests.
65
+ self.user_agent = user_agent or "herald-python/" + __version__
66
+ self.http = httpx.AsyncClient(timeout=15, follow_redirects=False, headers={"Authorization": "Bearer " + self.key, "User-Agent": self.user_agent}, transport=transport)
67
+ # One socket shared by every wait() and watch() on this client.
68
+ self.live = LiveSocket(self.socket)
69
+
70
+ async def __aenter__(self):
71
+ return self
72
+
73
+ async def __aexit__(self, *_):
74
+ await self.close()
75
+
76
+ async def close(self):
77
+ await self.http.aclose()
78
+
79
+ async def request(self, path, body=None, *, method=None, idempotency_key=None, retry=True):
80
+ """Calls `/api/v1` + `path`. Reads and keyed writes retry network errors and 5xx; any request
81
+ retries a rate limit that sends Retry-After, since the server rejected it unprocessed."""
82
+ response = await self._fetch("/api/v1" + path, body, method=method, idempotency_key=idempotency_key, retry=retry)
83
+ return read_json(response, parse_retry_after(response.headers.get("Retry-After")))
84
+
85
+ async def _fetch(self, path, body=None, *, method=None, idempotency_key=None, retry=True, stream=False):
86
+ safe = body is None or bool(idempotency_key)
87
+ retries = RETRIES if retry else 0
88
+ headers = {"Idempotency-Key": idempotency_key} if idempotency_key else {}
89
+ attempt = 0
90
+ while True:
91
+ try:
92
+ request = self.http.build_request(method or ("GET" if body is None else "POST"), self.url + path, json=wire(body), headers=headers)
93
+ response = await self.http.send(request, stream=stream)
94
+ except httpx.TransportError:
95
+ if attempt >= retries or not safe:
96
+ raise
97
+ await asyncio.sleep(retry_delay(attempt))
98
+ attempt += 1
99
+ continue
100
+ if 300 <= response.status_code < 400:
101
+ await response.aclose()
102
+ raise HeraldError("Herald does not accept API redirects.", response.status_code)
103
+ retry_after = parse_retry_after(response.headers.get("Retry-After"))
104
+ status = response.status_code
105
+ again = (safe or retry_after is not None) if status == 429 else (status >= 500 and safe)
106
+ if again and attempt < retries and (retry_after or 0) <= self.max_retry_after:
107
+ await response.aclose()
108
+ await asyncio.sleep(retry_delay(attempt) if retry_after is None else retry_after)
109
+ attempt += 1
110
+ continue
111
+ return response
112
+
113
+ async def ping(self):
114
+ """Public liveness check; needs no token."""
115
+ return Ping.model_validate(read_json(await self._fetch("/api/ping")))
116
+
117
+ async def token(self):
118
+ """The calling token's name, scopes and account."""
119
+ return TokenInfo.model_validate(await self.request("/token"))
120
+
121
+ async def revoke_token(self):
122
+ """Revokes the calling token; later requests with it fail with 401."""
123
+ await self.request("/token", method="DELETE")
124
+
125
+ async def account(self):
126
+ """Plan, usage and limits of the token's account."""
127
+ return Account.model_validate(await self.request("/account"))
128
+
129
+ async def send(self, *, idempotency_key=None, **message):
130
+ return Message.model_validate(await self.request("/messages", message, idempotency_key=idempotency_key or str(uuid.uuid4())))
131
+
132
+ async def get(self, message_id):
133
+ return Message.model_validate(await self.request("/messages/" + path(message_id)))
134
+
135
+ async def messages(self, **query):
136
+ """One page of the revision feed (`after=`, with `Tombstone`s for `tombstones=1`), or
137
+ messages matching `status`/`direction`."""
138
+ search = urlencode({key: value for key, value in query.items() if value is not None})
139
+ page = await self.request("/messages" + ("?" + search if search else ""))
140
+ return [(Tombstone if m.get("deleted") is True else Message).model_validate(m) for m in page]
141
+
142
+ async def thread(self, message_id, *, after=0):
143
+ return ThreadPage.model_validate(await self.request("/messages/" + path(message_id) + "/thread?after=" + str(after)))
144
+
145
+ async def thread_messages(self, message_id):
146
+ after = 0
147
+ while True:
148
+ page = await self.thread(message_id, after=after)
149
+ for message in page.messages:
150
+ yield message
151
+ if page.next is None:
152
+ return
153
+ after = page.next
154
+
155
+ async def cancel(self, message_id):
156
+ """Cancels a pending request this integration sent."""
157
+ return Message.model_validate(await self.request("/messages/" + path(message_id) + "/cancel", {}))
158
+
159
+ async def task_logs(self, message_id, *, after=None):
160
+ """A task's retained log lines after `after` (a sequence number)."""
161
+ query = "" if after is None else "?after=" + str(int(after))
162
+ page = await self.request(f"/messages/{path(message_id)}/logs{query}")
163
+ return [TaskLog.model_validate(log) for log in page["logs"]]
164
+
165
+ async def download(self, file_id):
166
+ """An attachment's name and bytes."""
167
+ response = await self._fetch("/api/v1/files/" + path(file_id), stream=True)
168
+ try:
169
+ if response.is_error:
170
+ await response.aread()
171
+ raise_error(response, "File download failed.")
172
+ data = await response.aread()
173
+ finally:
174
+ await response.aclose()
175
+ name = file_name(response.headers.get("Content-Disposition")) or file_id
176
+ return FileDownload(name=name, type=response.headers.get("Content-Type", "application/octet-stream"), data=data)
177
+
178
+ async def register(self, **destination):
179
+ """Creates a destination, or updates the one registered with the same `key`."""
180
+ return Destination.model_validate(await self.request("/destinations", destination))
181
+
182
+ async def destinations(self):
183
+ return [Destination.model_validate(d) for d in await self.request("/destinations")]
184
+
185
+ async def update_destination(self, destination_id, **destination):
186
+ return Destination.model_validate(await self.request("/destinations/" + path(destination_id), destination, method="PUT"))
187
+
188
+ async def delete_destination(self, destination_id):
189
+ """Removes a destination and cancels its queued tasks."""
190
+ await self.request("/destinations/" + path(destination_id), method="DELETE")
191
+
192
+ async def rotate_destination_secret(self, destination_id):
193
+ """Replaces a webhook destination's signing secret, returned once."""
194
+ return Destination.model_validate(await self.request(f"/destinations/{path(destination_id)}/secret", {}))
195
+
196
+ async def claim_task(self, task_id, *, runner_id, heartbeat=False):
197
+ """`heartbeat=True` asks for a lease that lapses unless renewed with `heartbeat_task`."""
198
+ return TaskClaim.model_validate(await self.request(f"/tasks/{path(task_id)}/claim", {"runnerId": runner_id, "heartbeat": heartbeat}))
199
+
200
+ async def heartbeat_task(self, task_id, lease):
201
+ return Lease.model_validate(await self.request(f"/tasks/{path(task_id)}/heartbeat", {"lease": lease}))
202
+
203
+ async def update_task(self, task_id, *, lease=None, status=None, error=None, logs=None, idempotency_key=None, retry=True):
204
+ """Reports logs and/or the outcome; keyed log batches retry safely."""
205
+ update = {"lease": lease, "status": status, "error": error, "logs": logs}
206
+ body = {key: value for key, value in update.items() if value is not None}
207
+ return Message.model_validate(await self.request(f"/tasks/{path(task_id)}/update", body, idempotency_key=idempotency_key, retry=retry))
208
+
209
+ async def collect_secrets(self, message_id):
210
+ """Consumes the encrypted envelopes of an answered request's secret fields."""
211
+ envelopes = await self.request(f"/messages/{path(message_id)}/secrets", {})
212
+ return {field: Envelope.model_validate(envelope) for field, envelope in envelopes.items()}
213
+
214
+ async def reveal_secrets(self, message_id, private_key):
215
+ """`collect_secrets`, decrypted with the private JWK whose public key the request carried."""
216
+ return {field: unseal(envelope, private_key) for field, envelope in (await self.collect_secrets(message_id)).items()}
217
+
218
+ async def secret_receiver(self, message_id):
219
+ """For a `kind="secret"` offer: the receiver's claim and public key once a device claims it."""
220
+ return SecretReceiver.model_validate(await self.request(f"/messages/{path(message_id)}/secret-receiver"))
221
+
222
+ async def deliver_secret(self, message_id, *, claim_id, envelope):
223
+ """Deposits the value sealed to the claimed receiver's key; repeating the same deposit is safe."""
224
+ await self.request(f"/messages/{path(message_id)}/secret-delivery", {"claimId": claim_id, "envelope": envelope})
225
+
226
+ async def integrations(self):
227
+ """Human sessions only: the account's connections."""
228
+ return [Integration.model_validate(i) for i in await self.request("/integrations")]
229
+
230
+ async def create_integration(self, *, name, scopes):
231
+ """Human sessions only; the token is returned once. Not retried, so it never creates two."""
232
+ return CreatedIntegration.model_validate(await self.request("/integrations", {"name": name, "scopes": scopes}))
233
+
234
+ async def delete_integration(self, integration_id):
235
+ """Human sessions only: revokes the token and removes the connection's destinations."""
236
+ await self.request("/integrations/" + path(integration_id), method="DELETE")
237
+
238
+ def socket(self):
239
+ return OriginConnection(self.url.replace("http", "ws", 1) + "/ws", additional_headers={"Authorization": "Bearer " + self.key}, user_agent_header=self.user_agent, open_timeout=15)
240
+
241
+ async def wait(self, message_id, *, wait_timeout=3600):
242
+ if not 0 < wait_timeout <= 86400:
243
+ raise ValueError("Wait timeout must be between zero and 24 hours.")
244
+ event = self.live.subscribe()
245
+ try:
246
+ async with asyncio.timeout(wait_timeout):
247
+ while True:
248
+ event.clear()
249
+ try:
250
+ message = await self.get(message_id)
251
+ if message.status in FINAL:
252
+ return message
253
+ except HeraldError as error:
254
+ if not retryable(error):
255
+ raise
256
+ except httpx.TransportError:
257
+ pass
258
+ try:
259
+ await asyncio.wait_for(event.wait(), POLL)
260
+ except TimeoutError:
261
+ pass
262
+ except TimeoutError as error:
263
+ raise HeraldError("Wait timed out; resume with the message ID. The request remains available.", 408, message_id) from error
264
+ finally:
265
+ self.live.unsubscribe(event)
266
+
267
+ async def ask(self, *, title, schema=None, timeout=None, wait_timeout=3600, idempotency_key=None, **message):
268
+ result, model = await self._answer(title=title, schema=schema, timeout=timeout, wait_timeout=wait_timeout, idempotency_key=idempotency_key, **message)
269
+ values = result.response.values
270
+ return model.model_validate(values) if model else values
271
+
272
+ async def ask_action(self, *, title, actions, schema=None, timeout=None, wait_timeout=3600, idempotency_key=None, **message):
273
+ if not actions:
274
+ raise ValueError("ask_action requires declared actions.")
275
+ result, model = await self._answer(title=title, actions=actions, schema=schema, timeout=timeout, wait_timeout=wait_timeout, idempotency_key=idempotency_key, **message)
276
+ action_id = result.response and result.response.action_id
277
+ if not action_id or not any(action["id"] == action_id for action in actions):
278
+ raise HeraldError("The response does not identify a declared action.", 502, result.id)
279
+ values = result.response.values
280
+ return ActionAnswer(action_id=action_id, values=model.model_validate(values) if model else values)
281
+
282
+ async def _answer(self, *, title, schema=None, timeout=None, wait_timeout=3600, idempotency_key=None, **message):
283
+ model = schema if isinstance(schema, type) and issubclass(schema, BaseModel) else None
284
+ if schema is not None:
285
+ message["schema"] = model.model_json_schema() if model else schema
286
+ if timeout is not None:
287
+ if not isinstance(timeout, (float, int)) or not 0 < timeout <= 86400:
288
+ raise ValueError("Timeout must be seconds between zero and 24 hours.")
289
+ if "expiresAt" in message or "expiresIn" in message:
290
+ raise ValueError("Use timeout or expiresAt, not both.")
291
+ message["expiresIn"] = timeout
292
+ sent = await self.send(title=title, kind="form", idempotency_key=idempotency_key, **message)
293
+ result = await self.wait(sent.id, wait_timeout=wait_timeout)
294
+ if result.status != "answered":
295
+ raise HeraldError("Request " + result.status + ".", 410, sent.id)
296
+ return result, model
297
+
298
+ async def watch(self, *, after=0):
299
+ if not isinstance(after, int) or after < 0:
300
+ raise ValueError("Use a nonnegative revision.")
301
+ event = self.live.subscribe()
302
+ try:
303
+ while True:
304
+ event.clear()
305
+ try:
306
+ page = await self.messages(after=after)
307
+ except (HeraldError, httpx.TransportError) as error:
308
+ if isinstance(error, HeraldError) and not retryable(error):
309
+ raise
310
+ await asyncio.sleep(3)
311
+ continue
312
+ for message in page:
313
+ yield message
314
+ after = max(after, message.revision or 0)
315
+ if len(page) == 100:
316
+ continue
317
+ try:
318
+ await asyncio.wait_for(event.wait(), POLL)
319
+ except TimeoutError:
320
+ pass
321
+ finally:
322
+ self.live.unsubscribe(event)
323
+
324
+ async def upload(self, name, data, content_type="application/octet-stream", *, size=None, idle_timeout=60):
325
+ """Reserves and uploads bytes or an async iterable of bytes (pass `size`); aborts only
326
+ after `idle_timeout` seconds without progress."""
327
+ if isinstance(data, (bytes, bytearray)):
328
+ size = len(data)
329
+ data = chunks(data)
330
+ if not isinstance(size, int) or size < 0:
331
+ raise ValueError("Pass the byte size for streamed uploads.")
332
+ file = Attachment.model_validate(await self.request("/files", {"name": name, "type": content_type, "size": size}))
333
+ response = await self.http.put(self.url + "/api/v1/files/" + path(file.id), content=idle_body(data, idle_timeout), headers={"Content-Length": str(size)}, timeout=idle_timeout)
334
+ if not response.is_success:
335
+ raise_error(response, "File upload failed.")
336
+ return Attachment(id=file.id, name=file.name, size=file.size, type=file.type)
@@ -0,0 +1,54 @@
1
+ """Secret envelopes, compatible with the TypeScript SDK: RSA-OAEP-256 wraps an AES-256-GCM key."""
2
+ import base64
3
+ import os
4
+
5
+ from cryptography.hazmat.primitives import hashes
6
+ from cryptography.hazmat.primitives.asymmetric import padding, rsa
7
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
8
+
9
+ from .models import Envelope
10
+
11
+ OAEP = padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None)
12
+
13
+
14
+ def _b64url(number):
15
+ raw = number.to_bytes((number.bit_length() + 7) // 8, "big")
16
+ return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
17
+
18
+
19
+ def _int(value):
20
+ return int.from_bytes(base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)), "big")
21
+
22
+
23
+ def create_keys():
24
+ """A request key pair as JWK dicts: send `public_key` with the form, keep `private_key` private."""
25
+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
26
+ priv, pub = key.private_numbers(), key.public_key().public_numbers()
27
+ public = {"kty": "RSA", "n": _b64url(pub.n), "e": _b64url(pub.e), "alg": "RSA-OAEP-256", "ext": True, "key_ops": ["encrypt"]}
28
+ private = {**public, "key_ops": ["decrypt"], "d": _b64url(priv.d), "p": _b64url(priv.p), "q": _b64url(priv.q), "dp": _b64url(priv.dmp1), "dq": _b64url(priv.dmq1), "qi": _b64url(priv.iqmp)}
29
+ return public, private
30
+
31
+
32
+ def _public(jwk):
33
+ return rsa.RSAPublicNumbers(_int(jwk["e"]), _int(jwk["n"])).public_key()
34
+
35
+
36
+ def _private(jwk):
37
+ public = rsa.RSAPublicNumbers(_int(jwk["e"]), _int(jwk["n"]))
38
+ return rsa.RSAPrivateNumbers(_int(jwk["p"]), _int(jwk["q"]), _int(jwk["d"]), _int(jwk["dp"]), _int(jwk["dq"]), _int(jwk["qi"]), public).private_key()
39
+
40
+
41
+ def seal(value, public_key):
42
+ """Encrypts `value` to a receiver's public JWK."""
43
+ aes, iv = AESGCM.generate_key(bit_length=256), os.urandom(12)
44
+ data = AESGCM(aes).encrypt(iv, value.encode(), None)
45
+ encode = lambda raw: base64.b64encode(raw).decode()
46
+ return Envelope(key=encode(_public(public_key).encrypt(aes, OAEP)), iv=encode(iv), data=encode(data))
47
+
48
+
49
+ def unseal(envelope, private_key):
50
+ """Decrypts an envelope with the private JWK whose public key the request carried."""
51
+ if not isinstance(envelope, Envelope):
52
+ envelope = Envelope.model_validate(envelope)
53
+ aes = _private(private_key).decrypt(base64.b64decode(envelope.key), OAEP)
54
+ return AESGCM(aes).decrypt(base64.b64decode(envelope.iv), base64.b64decode(envelope.data), None).decode()
@@ -0,0 +1,100 @@
1
+ import asyncio
2
+ import math
3
+ import random
4
+ import re
5
+ import time
6
+ from email.utils import parsedate_to_datetime
7
+
8
+
9
+ class HeraldError(Exception):
10
+ def __init__(self, message, status, message_id=None, retry_after=None, code=None):
11
+ super().__init__(message)
12
+ self.status = status
13
+ self.message_id = message_id
14
+ # Seconds from the server's Retry-After header, when sent.
15
+ self.retry_after = retry_after
16
+ # Machine-readable code from the server's error body, e.g. "RATE_LIMITED".
17
+ self.code = code
18
+
19
+
20
+ def retryable(error):
21
+ return error.status == 429 or error.status >= 500
22
+
23
+
24
+ def parse_retry_after(value):
25
+ """Retry-After as seconds: delta-seconds or an HTTP date."""
26
+ if value is None or not value.strip():
27
+ return None
28
+ value = value.strip()
29
+ if value.isdigit():
30
+ return int(value)
31
+ try:
32
+ seconds = parsedate_to_datetime(value).timestamp() - time.time()
33
+ except (TypeError, ValueError):
34
+ return None
35
+ return max(0, math.ceil(seconds))
36
+
37
+
38
+ def retry_delay(attempt):
39
+ """Network and 5xx retry delay in seconds: 250 ms doubling, with 50-100% jitter."""
40
+ return 0.25 * 2 ** attempt * (0.5 + random.random() / 2)
41
+
42
+
43
+ def error_text(data):
44
+ error = data.get("error") if isinstance(data, dict) else None
45
+ if isinstance(error, dict):
46
+ return error.get("message")
47
+ return error or (data.get("message") if isinstance(data, dict) else None)
48
+
49
+
50
+ def error_code(data):
51
+ body = data if isinstance(data, dict) else {}
52
+ code = body.get("error").get("code") if isinstance(body.get("error"), dict) else body.get("code")
53
+ return code if isinstance(code, str) else None
54
+
55
+
56
+ def json_body(response):
57
+ if not re.match(r"application/([\w.-]+\+)?json\b", response.headers.get("Content-Type", ""), re.I):
58
+ return False, None
59
+ try:
60
+ return True, response.json()
61
+ except ValueError:
62
+ return True, None
63
+
64
+
65
+ def raise_error(response, fallback):
66
+ """Raises the failed response's error message and code."""
67
+ _, data = json_body(response)
68
+ raise HeraldError(error_text(data) or fallback, response.status_code, retry_after=parse_retry_after(response.headers.get("Retry-After")), code=error_code(data))
69
+
70
+
71
+ def read_json(response, retry_after=None):
72
+ """Checks status and content type before parsing, so proxies' HTML pages never reach JSON."""
73
+ is_json, data = json_body(response)
74
+ if response.is_error:
75
+ raise HeraldError(error_text(data) or f"Herald request failed ({response.status_code}).", response.status_code, retry_after=retry_after, code=error_code(data))
76
+ if response.status_code == 204:
77
+ return None
78
+ if not is_json or data is None:
79
+ raise HeraldError("Herald returned a response that is not JSON.", response.status_code)
80
+ return data
81
+
82
+
83
+ async def chunks(data, size=1 << 14):
84
+ for start in range(0, len(data), size):
85
+ yield bytes(data[start:start + size])
86
+
87
+
88
+ async def idle_body(source, idle_timeout):
89
+ """Yields `source` in 16 KiB slices, so each socket write is small progress that resets
90
+ httpx's write timeout; raises when the source stalls for `idle_timeout` seconds."""
91
+ iterator = aiter(source)
92
+ while True:
93
+ try:
94
+ chunk = await asyncio.wait_for(anext(iterator), idle_timeout)
95
+ except StopAsyncIteration:
96
+ return
97
+ except TimeoutError as error:
98
+ raise HeraldError(f"Upload stalled for {idle_timeout} s.", 408) from error
99
+ for start in range(0, len(chunk), 1 << 14):
100
+ yield bytes(chunk[start:start + (1 << 14)])