ads_dev 1.0.0__py3-none-any.whl
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.
- ads_dev/__init__.py +40 -0
- ads_dev/_async/client.py +335 -0
- ads_dev/_core.py +101 -0
- ads_dev/_sync/client.py +335 -0
- ads_dev/py.typed +0 -0
- ads_dev/types.py +2126 -0
- ads_dev-1.0.0.dist-info/METADATA +158 -0
- ads_dev-1.0.0.dist-info/RECORD +10 -0
- ads_dev-1.0.0.dist-info/WHEEL +4 -0
- ads_dev-1.0.0.dist-info/licenses/LICENSE +21 -0
ads_dev/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from ._async.client import AsyncSandstorm
|
|
2
|
+
from ._core import GenerationFailed, SandstormError, is_finished, verify_webhook
|
|
3
|
+
from ._sync.client import Sandstorm
|
|
4
|
+
from .types import (
|
|
5
|
+
Ad,
|
|
6
|
+
AdSummary,
|
|
7
|
+
Advertiser,
|
|
8
|
+
Asset,
|
|
9
|
+
Brand,
|
|
10
|
+
Character,
|
|
11
|
+
Generation,
|
|
12
|
+
GenerationEvent,
|
|
13
|
+
LibraryAsset,
|
|
14
|
+
Model,
|
|
15
|
+
Offer,
|
|
16
|
+
OfferSummary,
|
|
17
|
+
Voice,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"Ad",
|
|
22
|
+
"AdSummary",
|
|
23
|
+
"Advertiser",
|
|
24
|
+
"Asset",
|
|
25
|
+
"AsyncSandstorm",
|
|
26
|
+
"Brand",
|
|
27
|
+
"Character",
|
|
28
|
+
"Generation",
|
|
29
|
+
"GenerationEvent",
|
|
30
|
+
"GenerationFailed",
|
|
31
|
+
"LibraryAsset",
|
|
32
|
+
"Model",
|
|
33
|
+
"Offer",
|
|
34
|
+
"OfferSummary",
|
|
35
|
+
"Sandstorm",
|
|
36
|
+
"SandstormError",
|
|
37
|
+
"Voice",
|
|
38
|
+
"is_finished",
|
|
39
|
+
"verify_webhook",
|
|
40
|
+
]
|
ads_dev/_async/client.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import uuid
|
|
5
|
+
from collections.abc import AsyncIterator, Mapping
|
|
6
|
+
from typing import Any, Generic, TypeVar
|
|
7
|
+
from urllib.parse import quote
|
|
8
|
+
|
|
9
|
+
import anyio
|
|
10
|
+
import httpx
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from .._core import (
|
|
14
|
+
LONGEST_WAIT_S,
|
|
15
|
+
RETRYABLE,
|
|
16
|
+
RUN_TIMEOUT_S,
|
|
17
|
+
GenerationFailed,
|
|
18
|
+
delay_of,
|
|
19
|
+
headers_of,
|
|
20
|
+
is_finished,
|
|
21
|
+
query_of,
|
|
22
|
+
refusal_of,
|
|
23
|
+
settings,
|
|
24
|
+
)
|
|
25
|
+
from ..types import (
|
|
26
|
+
Ad,
|
|
27
|
+
AdList,
|
|
28
|
+
AdSummary,
|
|
29
|
+
Advertiser,
|
|
30
|
+
AdvertiserList,
|
|
31
|
+
Asset,
|
|
32
|
+
Brand,
|
|
33
|
+
Character,
|
|
34
|
+
CharacterList,
|
|
35
|
+
Generation,
|
|
36
|
+
GenerationList,
|
|
37
|
+
LibraryAsset,
|
|
38
|
+
LibraryAssetList,
|
|
39
|
+
Model,
|
|
40
|
+
ModelList,
|
|
41
|
+
Offer,
|
|
42
|
+
OfferList,
|
|
43
|
+
OfferSummary,
|
|
44
|
+
Voice,
|
|
45
|
+
VoiceList,
|
|
46
|
+
WebhookSecret,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
Item = TypeVar("Item", bound=BaseModel)
|
|
50
|
+
|
|
51
|
+
Answer = TypeVar("Answer", bound=BaseModel)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Page(Generic[Item]):
|
|
55
|
+
def __init__(self, client: AsyncSandstorm, path: str, params: Mapping[str, Any], shape: type[BaseModel]) -> None:
|
|
56
|
+
self._client = client
|
|
57
|
+
self._path = path
|
|
58
|
+
self._params = params
|
|
59
|
+
self._shape = shape
|
|
60
|
+
|
|
61
|
+
async def first_page(self) -> Any:
|
|
62
|
+
return await self._client._send("GET", self._path, self._shape, query=self._params)
|
|
63
|
+
|
|
64
|
+
def __aiter__(self) -> AsyncIterator[Item]:
|
|
65
|
+
return self._items()
|
|
66
|
+
|
|
67
|
+
async def _items(self) -> AsyncIterator[Item]:
|
|
68
|
+
cursor: str | None = None
|
|
69
|
+
while True:
|
|
70
|
+
page = await self._client._send("GET", self._path, self._shape, query={**self._params, "cursor": cursor})
|
|
71
|
+
for item in page.data:
|
|
72
|
+
yield item
|
|
73
|
+
if not page.has_more or page.next_cursor is None:
|
|
74
|
+
return
|
|
75
|
+
cursor = page.next_cursor
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Generations:
|
|
79
|
+
def __init__(self, client: AsyncSandstorm) -> None:
|
|
80
|
+
self._client = client
|
|
81
|
+
|
|
82
|
+
async def create(
|
|
83
|
+
self,
|
|
84
|
+
model: str,
|
|
85
|
+
input: Mapping[str, Any],
|
|
86
|
+
*,
|
|
87
|
+
webhook_url: str | None = None,
|
|
88
|
+
wait: int | None = None,
|
|
89
|
+
idempotency_key: str | None = None,
|
|
90
|
+
) -> Generation:
|
|
91
|
+
body = {"model": model, "input": dict(input), **({"webhook_url": webhook_url} if webhook_url else {})}
|
|
92
|
+
return await self._client._send(
|
|
93
|
+
"POST", "/v1/generations", Generation, body=body, wait=wait, idempotency_key=idempotency_key or str(uuid.uuid4())
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
async def get(self, id: str, *, wait: int | None = None) -> Generation:
|
|
97
|
+
return await self._client._send("GET", f"/v1/generations/{quote(id, safe='')}", Generation, wait=wait)
|
|
98
|
+
|
|
99
|
+
async def wait(self, id: str, *, timeout: float = RUN_TIMEOUT_S) -> Generation:
|
|
100
|
+
deadline = anyio.current_time() + timeout
|
|
101
|
+
while True:
|
|
102
|
+
left = math.ceil(deadline - anyio.current_time())
|
|
103
|
+
generation = await self.get(id, wait=max(1, min(LONGEST_WAIT_S, left)))
|
|
104
|
+
if is_finished(generation):
|
|
105
|
+
return generation
|
|
106
|
+
if left <= 0:
|
|
107
|
+
raise TimeoutError(f"{id} did not finish within {timeout} s; it is still {generation.status}")
|
|
108
|
+
|
|
109
|
+
async def run(
|
|
110
|
+
self,
|
|
111
|
+
model: str,
|
|
112
|
+
input: Mapping[str, Any],
|
|
113
|
+
*,
|
|
114
|
+
webhook_url: str | None = None,
|
|
115
|
+
idempotency_key: str | None = None,
|
|
116
|
+
timeout: float = RUN_TIMEOUT_S,
|
|
117
|
+
) -> Generation:
|
|
118
|
+
created = await self.create(model, input, webhook_url=webhook_url, wait=LONGEST_WAIT_S, idempotency_key=idempotency_key)
|
|
119
|
+
generation = created if is_finished(created) else await self.wait(created.id, timeout=timeout)
|
|
120
|
+
if generation.status != "succeeded":
|
|
121
|
+
raise GenerationFailed(generation)
|
|
122
|
+
return generation
|
|
123
|
+
|
|
124
|
+
async def cancel(self, id: str) -> Generation:
|
|
125
|
+
return await self._client._send("POST", f"/v1/generations/{quote(id, safe='')}/cancel", Generation)
|
|
126
|
+
|
|
127
|
+
def list(self, *, status: str | None = None, model: str | None = None, limit: int | None = None) -> Page[Generation]:
|
|
128
|
+
return Page(self._client, "/v1/generations", {"status": status, "model": model, "limit": limit}, GenerationList)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Models:
|
|
132
|
+
def __init__(self, client: AsyncSandstorm) -> None:
|
|
133
|
+
self._client = client
|
|
134
|
+
|
|
135
|
+
def list(self, *, type: str | None = None) -> Page[Model]:
|
|
136
|
+
return Page(self._client, "/v1/models", {"type": type}, ModelList)
|
|
137
|
+
|
|
138
|
+
async def get(self, id: str) -> Model:
|
|
139
|
+
return await self._client._send("GET", f"/v1/models/{quote(id, safe='')}", Model)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class Assets:
|
|
143
|
+
def __init__(self, client: AsyncSandstorm) -> None:
|
|
144
|
+
self._client = client
|
|
145
|
+
|
|
146
|
+
def list(
|
|
147
|
+
self,
|
|
148
|
+
*,
|
|
149
|
+
type: str | None = None,
|
|
150
|
+
offer: str | None = None,
|
|
151
|
+
liked: bool | None = None,
|
|
152
|
+
archived: bool | None = None,
|
|
153
|
+
limit: int | None = None,
|
|
154
|
+
) -> Page[LibraryAsset]:
|
|
155
|
+
return Page(self._client, "/v1/assets", {"type": type, "offer": offer, "liked": liked, "archived": archived, "limit": limit}, LibraryAssetList)
|
|
156
|
+
|
|
157
|
+
async def get(self, id: str) -> Asset:
|
|
158
|
+
return await self._client._send("GET", f"/v1/assets/{quote(id, safe='')}", Asset)
|
|
159
|
+
|
|
160
|
+
async def import_url(self, url: str, *, name: str | None = None, idempotency_key: str | None = None) -> Asset:
|
|
161
|
+
body = {"url": url, **({"name": name} if name else {})}
|
|
162
|
+
return await self._client._send("POST", "/v1/assets", Asset, body=body, idempotency_key=idempotency_key or str(uuid.uuid4()))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class Brands:
|
|
166
|
+
def __init__(self, client: AsyncSandstorm) -> None:
|
|
167
|
+
self._client = client
|
|
168
|
+
|
|
169
|
+
async def get(self) -> Brand:
|
|
170
|
+
return await self._client._send("GET", "/v1/brand", Brand)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class Offers:
|
|
174
|
+
def __init__(self, client: AsyncSandstorm) -> None:
|
|
175
|
+
self._client = client
|
|
176
|
+
|
|
177
|
+
def list(self, *, query: str | None = None, limit: int | None = None) -> Page[OfferSummary]:
|
|
178
|
+
return Page(self._client, "/v1/offers", {"query": query, "limit": limit}, OfferList)
|
|
179
|
+
|
|
180
|
+
async def get(self, id: str) -> Offer:
|
|
181
|
+
return await self._client._send("GET", f"/v1/offers/{quote(id, safe='')}", Offer)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class Characters:
|
|
185
|
+
def __init__(self, client: AsyncSandstorm) -> None:
|
|
186
|
+
self._client = client
|
|
187
|
+
|
|
188
|
+
def list(self, *, query: str | None = None, kind: str | None = None) -> Page[Character]:
|
|
189
|
+
return Page(self._client, "/v1/characters", {"query": query, "kind": kind}, CharacterList)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class Voices:
|
|
193
|
+
def __init__(self, client: AsyncSandstorm) -> None:
|
|
194
|
+
self._client = client
|
|
195
|
+
|
|
196
|
+
def list(
|
|
197
|
+
self,
|
|
198
|
+
*,
|
|
199
|
+
query: str | None = None,
|
|
200
|
+
gender: str | None = None,
|
|
201
|
+
age: str | None = None,
|
|
202
|
+
accent: str | None = None,
|
|
203
|
+
use_case: str | None = None,
|
|
204
|
+
limit: int | None = None,
|
|
205
|
+
) -> Page[Voice]:
|
|
206
|
+
params = {"query": query, "gender": gender, "age": age, "accent": accent, "use_case": use_case, "limit": limit}
|
|
207
|
+
return Page(self._client, "/v1/voices", params, VoiceList)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class Advertisers:
|
|
211
|
+
def __init__(self, client: AsyncSandstorm) -> None:
|
|
212
|
+
self._client = client
|
|
213
|
+
|
|
214
|
+
def list(self, *, query: str, limit: int | None = None) -> Page[Advertiser]:
|
|
215
|
+
return Page(self._client, "/v1/advertisers", {"query": query, "limit": limit}, AdvertiserList)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class Ads:
|
|
219
|
+
def __init__(self, client: AsyncSandstorm) -> None:
|
|
220
|
+
self._client = client
|
|
221
|
+
|
|
222
|
+
def list(
|
|
223
|
+
self,
|
|
224
|
+
*,
|
|
225
|
+
query: str | None = None,
|
|
226
|
+
advertiser: str | None = None,
|
|
227
|
+
platform: str | None = None,
|
|
228
|
+
format: str | None = None,
|
|
229
|
+
status: str | None = None,
|
|
230
|
+
min_active_days: int | None = None,
|
|
231
|
+
started_after: str | None = None,
|
|
232
|
+
started_before: str | None = None,
|
|
233
|
+
limit: int | None = None,
|
|
234
|
+
) -> Page[AdSummary]:
|
|
235
|
+
params = {
|
|
236
|
+
"query": query,
|
|
237
|
+
"advertiser": advertiser,
|
|
238
|
+
"platform": platform,
|
|
239
|
+
"format": format,
|
|
240
|
+
"status": status,
|
|
241
|
+
"min_active_days": min_active_days,
|
|
242
|
+
"started_after": started_after,
|
|
243
|
+
"started_before": started_before,
|
|
244
|
+
"limit": limit,
|
|
245
|
+
}
|
|
246
|
+
return Page(self._client, "/v1/ads", params, AdList)
|
|
247
|
+
|
|
248
|
+
async def get(self, id: str) -> Ad:
|
|
249
|
+
return await self._client._send("GET", f"/v1/ads/{quote(id, safe='')}", Ad)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class Webhooks:
|
|
253
|
+
def __init__(self, client: AsyncSandstorm) -> None:
|
|
254
|
+
self._client = client
|
|
255
|
+
|
|
256
|
+
async def secret(self) -> str:
|
|
257
|
+
return (await self._client._send("GET", "/v1/webhooks/secret", WebhookSecret)).secret
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class AsyncSandstorm:
|
|
261
|
+
def __init__(
|
|
262
|
+
self,
|
|
263
|
+
*,
|
|
264
|
+
api_key: str | None = None,
|
|
265
|
+
base_url: str | None = None,
|
|
266
|
+
brand: str | None = None,
|
|
267
|
+
max_retries: int = 2,
|
|
268
|
+
timeout: float = 60,
|
|
269
|
+
http: httpx.AsyncClient | None = None,
|
|
270
|
+
) -> None:
|
|
271
|
+
key, url = settings(api_key, base_url)
|
|
272
|
+
self._base_url = url
|
|
273
|
+
self._headers = headers_of(key, brand)
|
|
274
|
+
self._max_retries = max_retries
|
|
275
|
+
self._timeout = timeout
|
|
276
|
+
self._http = http or httpx.AsyncClient()
|
|
277
|
+
self.generations = Generations(self)
|
|
278
|
+
self.models = Models(self)
|
|
279
|
+
self.assets = Assets(self)
|
|
280
|
+
self.brand = Brands(self)
|
|
281
|
+
self.offers = Offers(self)
|
|
282
|
+
self.characters = Characters(self)
|
|
283
|
+
self.voices = Voices(self)
|
|
284
|
+
self.advertisers = Advertisers(self)
|
|
285
|
+
self.ads = Ads(self)
|
|
286
|
+
self.webhooks = Webhooks(self)
|
|
287
|
+
|
|
288
|
+
async def __aenter__(self) -> AsyncSandstorm:
|
|
289
|
+
return self
|
|
290
|
+
|
|
291
|
+
async def __aexit__(self, *_: object) -> None:
|
|
292
|
+
await self.aclose()
|
|
293
|
+
|
|
294
|
+
async def aclose(self) -> None:
|
|
295
|
+
await self._http.aclose()
|
|
296
|
+
|
|
297
|
+
async def _send(
|
|
298
|
+
self,
|
|
299
|
+
method: str,
|
|
300
|
+
path: str,
|
|
301
|
+
shape: type[Answer],
|
|
302
|
+
*,
|
|
303
|
+
query: Mapping[str, Any] | None = None,
|
|
304
|
+
body: Mapping[str, Any] | None = None,
|
|
305
|
+
wait: int | None = None,
|
|
306
|
+
idempotency_key: str | None = None,
|
|
307
|
+
) -> Answer:
|
|
308
|
+
headers = {
|
|
309
|
+
**self._headers,
|
|
310
|
+
**({"prefer": f"wait={wait}"} if wait is not None else {}),
|
|
311
|
+
**({"idempotency-key": idempotency_key} if idempotency_key is not None else {}),
|
|
312
|
+
}
|
|
313
|
+
attempt = 0
|
|
314
|
+
while True:
|
|
315
|
+
try:
|
|
316
|
+
response = await self._http.request(
|
|
317
|
+
method,
|
|
318
|
+
self._base_url + path,
|
|
319
|
+
params=query_of(query or {}),
|
|
320
|
+
json=body,
|
|
321
|
+
headers=headers,
|
|
322
|
+
timeout=self._timeout + (wait or 0),
|
|
323
|
+
)
|
|
324
|
+
except httpx.TransportError:
|
|
325
|
+
if attempt >= self._max_retries:
|
|
326
|
+
raise
|
|
327
|
+
await anyio.sleep(delay_of(attempt, None))
|
|
328
|
+
attempt += 1
|
|
329
|
+
continue
|
|
330
|
+
if response.is_success:
|
|
331
|
+
return shape.model_validate(response.json())
|
|
332
|
+
if response.status_code not in RETRYABLE or attempt >= self._max_retries:
|
|
333
|
+
raise refusal_of(response)
|
|
334
|
+
await anyio.sleep(delay_of(attempt, response))
|
|
335
|
+
attempt += 1
|
ads_dev/_core.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import random
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from .types import Generation, GenerationEvent
|
|
16
|
+
|
|
17
|
+
BASE_URL = "https://ads.dev"
|
|
18
|
+
|
|
19
|
+
LONGEST_WAIT_S = 60
|
|
20
|
+
|
|
21
|
+
RUN_TIMEOUT_S = 60 * 60
|
|
22
|
+
|
|
23
|
+
RETRYABLE = frozenset({408, 429, 500, 502, 503, 504})
|
|
24
|
+
|
|
25
|
+
FINISHED = frozenset({"succeeded", "failed", "canceled"})
|
|
26
|
+
|
|
27
|
+
MISSING_KEY = (
|
|
28
|
+
"Pass api_key or set SANDSTORM_API_KEY. To make a key: "
|
|
29
|
+
"curl -fsSL https://ads.dev/login.mjs -o sandstorm-login.mjs && node sandstorm-login.mjs"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SandstormError(Exception):
|
|
34
|
+
def __init__(self, status: int, refusal: Mapping[str, Any]) -> None:
|
|
35
|
+
super().__init__(refusal.get("message", ""))
|
|
36
|
+
self.status = status
|
|
37
|
+
self.type: str = refusal.get("type", "api_error")
|
|
38
|
+
self.code: str = refusal.get("code", "unreadable")
|
|
39
|
+
self.param: str | None = refusal.get("param")
|
|
40
|
+
self.request_id: str | None = refusal.get("request_id")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class GenerationFailed(Exception):
|
|
44
|
+
def __init__(self, generation: Generation) -> None:
|
|
45
|
+
super().__init__(generation.error.message if generation.error else f"{generation.id} was {generation.status}")
|
|
46
|
+
self.generation = generation
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_finished(generation: Generation) -> bool:
|
|
50
|
+
return generation.status in FINISHED
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def settings(api_key: str | None, base_url: str | None) -> tuple[str, str]:
|
|
54
|
+
key = api_key or os.environ.get("SANDSTORM_API_KEY")
|
|
55
|
+
if not key:
|
|
56
|
+
raise ValueError(MISSING_KEY)
|
|
57
|
+
return key, (base_url or os.environ.get("SANDSTORM_API_URL") or BASE_URL).rstrip("/")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def headers_of(api_key: str, brand: str | None) -> dict[str, str]:
|
|
61
|
+
return {"authorization": f"Bearer {api_key}", **({"sandstorm-brand": brand} if brand else {})}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def query_of(params: Mapping[str, Any]) -> dict[str, str]:
|
|
65
|
+
return {name: ("true" if value is True else "false" if value is False else str(value)) for name, value in params.items() if value is not None}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def refusal_of(response: httpx.Response) -> SandstormError:
|
|
69
|
+
try:
|
|
70
|
+
return SandstormError(response.status_code, response.json()["error"])
|
|
71
|
+
except (ValueError, KeyError, TypeError):
|
|
72
|
+
return SandstormError(
|
|
73
|
+
response.status_code,
|
|
74
|
+
{"message": response.text or response.reason_phrase, "request_id": response.headers.get("sandstorm-request-id")},
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def delay_of(attempt: int, response: httpx.Response | None) -> float:
|
|
79
|
+
try:
|
|
80
|
+
after = float(response.headers["retry-after"]) if response is not None else 0
|
|
81
|
+
except (KeyError, ValueError):
|
|
82
|
+
after = 0
|
|
83
|
+
return after if after > 0 else min(8.0, 0.5 * 2**attempt) * random.uniform(0.75, 1.25)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
SIGNATURE = re.compile(r"^[0-9a-f]{64}$")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def verify_webhook(secret: str, body: str | bytes, signature: str | None, *, tolerance_seconds: float = 300, now: float | None = None) -> GenerationEvent:
|
|
90
|
+
text = body.decode() if isinstance(body, bytes) else body
|
|
91
|
+
parts = dict(part.split("=", 1) for part in (signature or "").split(",") if "=" in part)
|
|
92
|
+
given = parts.get("v1", "")
|
|
93
|
+
if not parts.get("t", "").isdigit() or not SIGNATURE.match(given):
|
|
94
|
+
raise ValueError("The Sandstorm-Signature header is missing or malformed.")
|
|
95
|
+
timestamp = int(parts["t"])
|
|
96
|
+
if abs((time.time() if now is None else now) - timestamp) > tolerance_seconds:
|
|
97
|
+
raise ValueError("The webhook is older than the tolerance; refuse it as a possible replay.")
|
|
98
|
+
expected = hmac.new(secret.encode(), f"{timestamp}.{text}".encode(), hashlib.sha256).hexdigest()
|
|
99
|
+
if not hmac.compare_digest(expected, given):
|
|
100
|
+
raise ValueError("The webhook signature does not match; it was not sent by Sandstorm with this secret.")
|
|
101
|
+
return GenerationEvent.model_validate(json.loads(text))
|