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 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
+ ]
@@ -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))