reedapt 0.1.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.
reedapt/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ """The official Python library for the Reedapt API. See https://docs.reedapt.com."""
2
+
3
+ from . import types
4
+ from ._async_client import AsyncReedapt
5
+ from ._base import RequestOptions
6
+ from ._client import Reedapt
7
+ from ._errors import (
8
+ APIError,
9
+ AuthenticationError,
10
+ ConflictError,
11
+ ConnectionError,
12
+ InvalidRequestError,
13
+ NotFoundError,
14
+ RateLimitError,
15
+ ReedaptError,
16
+ ServerError,
17
+ TimeoutError,
18
+ UploadError,
19
+ WaitError,
20
+ WebhookVerificationError,
21
+ )
22
+ from ._version import __version__
23
+ from ._webhooks import construct_webhook_event, verify_webhook
24
+
25
+ __all__ = [
26
+ "Reedapt",
27
+ "AsyncReedapt",
28
+ "RequestOptions",
29
+ "types",
30
+ "verify_webhook",
31
+ "construct_webhook_event",
32
+ "ReedaptError",
33
+ "APIError",
34
+ "AuthenticationError",
35
+ "NotFoundError",
36
+ "ConflictError",
37
+ "InvalidRequestError",
38
+ "RateLimitError",
39
+ "ServerError",
40
+ "ConnectionError",
41
+ "TimeoutError",
42
+ "UploadError",
43
+ "WaitError",
44
+ "WebhookVerificationError",
45
+ "__version__",
46
+ ]
@@ -0,0 +1,481 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import Any, AsyncIterator, Callable, Dict, List, Literal, Mapping, Optional, Sequence
5
+
6
+ import httpx
7
+ from typing_extensions import Self, Unpack
8
+
9
+ from . import _base
10
+ from ._base import FileInput, RequestOptions, Retry, Source
11
+ from ._client import ProgressCallback, check_upload
12
+ from ._errors import ConflictError, ReedaptError, UploadError, WaitError
13
+ from ._webhooks import Webhooks
14
+ from .types import (
15
+ Dub,
16
+ DubFiles,
17
+ DubList,
18
+ DubsCreated,
19
+ Languages,
20
+ Transcript,
21
+ Upload,
22
+ UploadedPart,
23
+ Usage,
24
+ UsageReport,
25
+ WebhookDelivery,
26
+ WebhookDeliveryList,
27
+ WebhookEndpoint,
28
+ WebhookEndpointList,
29
+ )
30
+
31
+
32
+ class AsyncReedapt:
33
+ """The asyncio client. Same methods as ``Reedapt``, awaited.
34
+
35
+ ``api_key`` defaults to the ``REEDAPT_API_KEY`` environment variable.
36
+ Use ``async with``, or call ``await close()``, to release connections.
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ *,
42
+ api_key: Optional[str] = None,
43
+ base_url: Optional[str] = None,
44
+ timeout: float = _base.DEFAULT_TIMEOUT,
45
+ max_retries: int = _base.DEFAULT_MAX_RETRIES,
46
+ default_headers: Optional[Mapping[str, str]] = None,
47
+ http_client: Optional[httpx.AsyncClient] = None,
48
+ ) -> None:
49
+ self._settings = _base.Settings(api_key, base_url, timeout, max_retries, default_headers)
50
+ self._http = http_client or httpx.AsyncClient(timeout=timeout)
51
+ self._owns_http = http_client is None
52
+ self.uploads = AsyncUploads(self)
53
+ self.dubs = AsyncDubs(self)
54
+ self.webhook_endpoints = AsyncWebhookEndpoints(self)
55
+ self.webhooks = Webhooks()
56
+ self.languages = AsyncLanguagesResource(self)
57
+ self.usage = AsyncUsageResource(self)
58
+
59
+ @property
60
+ def base_url(self) -> str:
61
+ return self._settings.base_url
62
+
63
+ async def close(self) -> None:
64
+ if self._owns_http:
65
+ await self._http.aclose()
66
+
67
+ async def __aenter__(self) -> Self:
68
+ return self
69
+
70
+ async def __aexit__(self, *_: object) -> None:
71
+ await self.close()
72
+
73
+ async def dub(
74
+ self,
75
+ file: FileInput,
76
+ *,
77
+ source_language: str,
78
+ target_languages: Sequence[str],
79
+ filename: Optional[str] = None,
80
+ voice_cloning: Optional[bool] = None,
81
+ num_speakers: Optional[int] = None,
82
+ media_category: Optional[str] = None,
83
+ metadata: Optional[Mapping[str, str]] = None,
84
+ concurrency: int = 4,
85
+ on_progress: Optional[ProgressCallback] = None,
86
+ idempotency_key: Optional[str] = None,
87
+ ) -> DubsCreated:
88
+ """Uploads a file, or has Reedapt fetch a public ``https://`` link, and
89
+ starts a dub for each target language. Returns once the dubs have
90
+ started, not when they finish."""
91
+ if _base.is_link(file):
92
+ upload = await self.uploads.from_url(
93
+ str(file), filename=filename, idempotency_key=_base.suffixed(idempotency_key, "upload")
94
+ )
95
+ else:
96
+ upload = await self.uploads.upload_file(
97
+ file,
98
+ filename=filename,
99
+ concurrency=concurrency,
100
+ on_progress=on_progress,
101
+ idempotency_key=_base.suffixed(idempotency_key, "upload"),
102
+ )
103
+ dub_options: RequestOptions = {}
104
+ if idempotency_key:
105
+ dub_options["idempotency_key"] = f"{idempotency_key}:dubs"
106
+ return await self.dubs.create(
107
+ upload_id=upload["id"],
108
+ source_language=source_language,
109
+ target_languages=target_languages,
110
+ voice_cloning=voice_cloning,
111
+ num_speakers=num_speakers,
112
+ media_category=media_category,
113
+ metadata=metadata,
114
+ **dub_options,
115
+ )
116
+
117
+ async def _request(
118
+ self,
119
+ method: str,
120
+ path: str,
121
+ *,
122
+ body: Any = None,
123
+ params: Optional[Dict[str, Any]] = None,
124
+ retry: Retry = "safe",
125
+ idempotent: bool = False,
126
+ text: bool = False,
127
+ options: Optional[RequestOptions] = None,
128
+ ) -> Any:
129
+ options = options or {}
130
+ key = _base.idempotency_key_for(idempotent, options)
131
+ headers = self._settings.headers(body is not None, key, options)
132
+ retry = "safe" if key else retry
133
+ timeout = options.get("timeout", self._settings.timeout)
134
+ max_retries = options.get("max_retries", self._settings.max_retries)
135
+ query = {k: v for k, v in (params or {}).items() if v is not None}
136
+ attempt = 0
137
+ while True:
138
+ try:
139
+ try:
140
+ response = await self._http.request(
141
+ method,
142
+ self._settings.base_url + path,
143
+ headers=headers,
144
+ json=body,
145
+ params=query or None,
146
+ timeout=timeout,
147
+ )
148
+ except httpx.TransportError as e:
149
+ raise _base.connection_error(method, path, e, timeout) from e
150
+ return _base.parse(response, text)
151
+ except ReedaptError as error:
152
+ if attempt >= max_retries or not _base.should_retry(error, retry):
153
+ raise
154
+ await asyncio.sleep(_base.retry_delay(error, attempt))
155
+ attempt += 1
156
+
157
+
158
+ class AsyncUploads:
159
+ def __init__(self, client: AsyncReedapt) -> None:
160
+ self._client = client
161
+
162
+ async def create(
163
+ self,
164
+ *,
165
+ filename: Optional[str] = None,
166
+ size: Optional[int] = None,
167
+ source_url: Optional[str] = None,
168
+ **options: Unpack[RequestOptions],
169
+ ) -> Upload:
170
+ """Starts an upload. Most code wants ``upload_file`` or ``from_url`` instead."""
171
+ body = {k: v for k, v in (("filename", filename), ("size", size), ("source_url", source_url)) if v is not None}
172
+ return await self._client._request("POST", "/uploads", body=body, idempotent=True, options=options)
173
+
174
+ async def sign_parts(
175
+ self, upload_id: str, part_numbers: Sequence[int], **options: Unpack[RequestOptions]
176
+ ) -> Dict[str, Any]:
177
+ """Signs links for up to 100 more parts."""
178
+ return await self._client._request(
179
+ "POST",
180
+ f"/uploads/{_base.quote(upload_id)}/parts",
181
+ body={"part_numbers": list(part_numbers)},
182
+ options=options,
183
+ )
184
+
185
+ async def complete(
186
+ self, upload_id: str, parts: Sequence[UploadedPart], **options: Unpack[RequestOptions]
187
+ ) -> Upload:
188
+ return await self._client._request(
189
+ "POST", f"/uploads/{_base.quote(upload_id)}/complete", body={"parts": list(parts)}, options=options
190
+ )
191
+
192
+ async def retrieve(self, upload_id: str, **options: Unpack[RequestOptions]) -> Upload:
193
+ return await self._client._request("GET", f"/uploads/{_base.quote(upload_id)}", options=options)
194
+
195
+ async def upload_file(
196
+ self,
197
+ file: FileInput,
198
+ *,
199
+ filename: Optional[str] = None,
200
+ concurrency: int = 4,
201
+ on_progress: Optional[ProgressCallback] = None,
202
+ idempotency_key: Optional[str] = None,
203
+ ) -> Upload:
204
+ """Sends a file to storage in parts, several at once, and completes the
205
+ upload. Expired links are signed again and failed parts retried.
206
+
207
+ ``file`` is a path, bytes (with ``filename``), or a binary file object.
208
+ Each part in flight (16 MiB or more) is held in memory."""
209
+ source = Source(file, filename)
210
+ try:
211
+ create_options: RequestOptions = {"idempotency_key": idempotency_key} if idempotency_key else {}
212
+ upload = await self.create(filename=source.filename, size=source.size, **create_options)
213
+ links: Dict[int, str] = {p["part_number"]: p["url"] for p in upload.get("parts") or []}
214
+ sign_lock = asyncio.Lock()
215
+ uploaded = 0
216
+
217
+ async def link_for(n: int, refresh: bool) -> str:
218
+ async with sign_lock:
219
+ if refresh:
220
+ links.pop(n, None)
221
+ if n not in links:
222
+ wanted = [n] if refresh else _base.sign_batch(n, upload["part_count"], links)
223
+ signed = await self.sign_parts(upload["id"], wanted)
224
+ links.update({p["part_number"]: p["url"] for p in signed["parts"]})
225
+ if n not in links:
226
+ raise UploadError(f"No link was signed for part {n} of {upload['id']}.")
227
+ return links[n]
228
+
229
+ queue = list(range(upload["part_count"], 0, -1))
230
+ parts: List[UploadedPart] = []
231
+
232
+ async def worker() -> None:
233
+ nonlocal uploaded
234
+ while queue:
235
+ n = queue.pop()
236
+ start, end = _base.part_range(n, upload["part_size"], source.size)
237
+ data = await asyncio.get_running_loop().run_in_executor(None, source.read, start, end)
238
+ etag = await self._put_part(n, data, link_for)
239
+ parts.append({"part_number": n, "etag": etag})
240
+ uploaded += end - start
241
+ if on_progress is not None:
242
+ on_progress(uploaded, source.size)
243
+
244
+ tasks = [asyncio.ensure_future(worker()) for _ in range(max(1, min(concurrency, upload["part_count"])))]
245
+ try:
246
+ await asyncio.gather(*tasks)
247
+ except BaseException:
248
+ # One failed part stops the others.
249
+ for task in tasks:
250
+ task.cancel()
251
+ await asyncio.gather(*tasks, return_exceptions=True)
252
+ raise
253
+ parts.sort(key=lambda p: p["part_number"])
254
+
255
+ try:
256
+ return await self.complete(upload["id"], parts)
257
+ except ConflictError as e:
258
+ # A retried complete that had already gone through.
259
+ if e.code == "UPLOAD_COMPLETE":
260
+ return await self.retrieve(upload["id"])
261
+ raise
262
+ finally:
263
+ source.close()
264
+
265
+ async def _put_part(self, n: int, data: bytes, link_for: Callable[[int, bool], Any]) -> str:
266
+ refresh = False
267
+ problem = ""
268
+ for attempt in range(_base.PART_ATTEMPTS):
269
+ if attempt:
270
+ await asyncio.sleep(_base.backoff(attempt))
271
+ url = await link_for(n, refresh)
272
+ try:
273
+ # No API key here: the signed link carries its own permission.
274
+ response = await self._client._http.put(
275
+ url, content=data, timeout=max(self._client._settings.timeout, 600)
276
+ )
277
+ except httpx.TransportError as e:
278
+ problem, refresh = str(e), False
279
+ continue
280
+ etag, refresh, problem = _base.part_outcome(n, response)
281
+ if etag:
282
+ return etag
283
+ raise UploadError(f"Part {n} failed: {problem}.")
284
+
285
+ async def from_url(
286
+ self,
287
+ source_url: str,
288
+ *,
289
+ filename: Optional[str] = None,
290
+ wait: bool = True,
291
+ poll_interval: float = 10,
292
+ timeout: float = 3 * 60 * 60,
293
+ idempotency_key: Optional[str] = None,
294
+ ) -> Upload:
295
+ """Has Reedapt download a public link. Waits until it's ready unless ``wait`` is False."""
296
+ options: RequestOptions = {"idempotency_key": idempotency_key} if idempotency_key else {}
297
+ upload = await self.create(source_url=source_url, filename=filename, **options)
298
+ if not wait:
299
+ return upload
300
+ return await self.wait_until_ready(upload["id"], poll_interval=poll_interval, timeout=timeout)
301
+
302
+ async def wait_until_ready(
303
+ self, upload_id: str, *, poll_interval: float = 10, timeout: float = 3 * 60 * 60
304
+ ) -> Upload:
305
+ """Polls an upload until it can be dubbed. Raises ``WaitError`` if it failed."""
306
+ loop = asyncio.get_running_loop()
307
+ deadline = loop.time() + timeout
308
+ while True:
309
+ upload = await self.retrieve(upload_id)
310
+ check_upload(upload)
311
+ if upload["status"] == "uploaded":
312
+ return upload
313
+ if loop.time() + poll_interval > deadline:
314
+ raise WaitError(f"Upload {upload_id} was still {upload['status']} when the wait ran out.")
315
+ await asyncio.sleep(poll_interval)
316
+
317
+
318
+ class AsyncDubs:
319
+ def __init__(self, client: AsyncReedapt) -> None:
320
+ self._client = client
321
+
322
+ async def create(
323
+ self,
324
+ *,
325
+ upload_id: str,
326
+ source_language: str,
327
+ target_languages: Sequence[str],
328
+ voice_cloning: Optional[bool] = None,
329
+ num_speakers: Optional[int] = None,
330
+ media_category: Optional[str] = None,
331
+ metadata: Optional[Mapping[str, str]] = None,
332
+ **options: Unpack[RequestOptions],
333
+ ) -> DubsCreated:
334
+ """Starts one dub per target language. Languages that can't start come back in ``refused``."""
335
+ body = _base.dub_body(
336
+ upload_id, source_language, list(target_languages), voice_cloning, num_speakers, media_category, metadata
337
+ )
338
+ return await self._client._request("POST", "/dubs", body=body, idempotent=True, options=options)
339
+
340
+ async def retrieve(self, dub_id: str, **options: Unpack[RequestOptions]) -> Dub:
341
+ return await self._client._request("GET", f"/dubs/{_base.quote(dub_id)}", options=options)
342
+
343
+ async def list(
344
+ self, *, limit: Optional[int] = None, offset: Optional[int] = None, **options: Unpack[RequestOptions]
345
+ ) -> DubList:
346
+ """One page of the account's dubs, newest first."""
347
+ return await self._client._request("GET", "/dubs", params={"limit": limit, "offset": offset}, options=options)
348
+
349
+ async def list_all(self, *, page_size: int = 100) -> AsyncIterator[Dub]:
350
+ """Every dub, newest first, fetching pages as you go."""
351
+ seen = set()
352
+ offset = 0
353
+ while True:
354
+ page = await self.list(limit=page_size, offset=offset)
355
+ for dub in page["data"]:
356
+ # Dubs started while paging shift later pages; skip the repeats.
357
+ if dub["id"] not in seen:
358
+ seen.add(dub["id"])
359
+ yield dub
360
+ if not page["has_more"] or not page["data"]:
361
+ return
362
+ offset += page_size
363
+
364
+ async def cancel(self, dub_id: str, **options: Unpack[RequestOptions]) -> None:
365
+ """Stops an unfinished dub and deletes it. Its minutes are returned."""
366
+ await self._client._request("POST", f"/dubs/{_base.quote(dub_id)}/cancel", options=options)
367
+
368
+ async def files(self, dub_id: str, **options: Unpack[RequestOptions]) -> DubFiles:
369
+ """Download links for a completed dub's files. Each link lasts an hour."""
370
+ return await self._client._request("GET", f"/dubs/{_base.quote(dub_id)}/files", options=options)
371
+
372
+ async def transcript(self, dub_id: str, **options: Unpack[RequestOptions]) -> Transcript:
373
+ """Every line of a completed dub with its timing, speaker, original words and translation."""
374
+ return await self._client._request("GET", f"/dubs/{_base.quote(dub_id)}/transcript", options=options)
375
+
376
+ async def subtitles(
377
+ self,
378
+ dub_id: str,
379
+ *,
380
+ format: Literal["srt", "vtt"] = "srt",
381
+ language: Literal["target", "source"] = "target",
382
+ **options: Unpack[RequestOptions],
383
+ ) -> str:
384
+ """A completed dub's subtitles as SRT or WebVTT text, in the dub's language
385
+ (``target``) or the original words (``source``)."""
386
+ return await self._client._request(
387
+ "GET",
388
+ f"/dubs/{_base.quote(dub_id)}/subtitles",
389
+ params={"format": format, "language": language},
390
+ text=True,
391
+ options=options,
392
+ )
393
+
394
+ async def wait_until_done(
395
+ self,
396
+ dub_id: str,
397
+ *,
398
+ poll_interval: float = 30,
399
+ timeout: float = 12 * 60 * 60,
400
+ on_update: Optional[Callable[[Dub], None]] = None,
401
+ ) -> Dub:
402
+ """Polls a dub until it is ``completed`` or ``failed`` and returns it.
403
+ Check ``status`` on the result. Webhooks are better for long dubs."""
404
+ loop = asyncio.get_running_loop()
405
+ deadline = loop.time() + timeout
406
+ while True:
407
+ dub = await self.retrieve(dub_id)
408
+ if on_update is not None:
409
+ on_update(dub)
410
+ if dub["status"] in ("completed", "failed"):
411
+ return dub
412
+ if loop.time() + poll_interval > deadline:
413
+ raise WaitError(f"Dub {dub_id} was still {dub['status']} when the wait ran out.")
414
+ await asyncio.sleep(poll_interval)
415
+
416
+
417
+ class AsyncWebhookEndpoints:
418
+ def __init__(self, client: AsyncReedapt) -> None:
419
+ self._client = client
420
+
421
+ async def create(
422
+ self, *, url: str, description: Optional[str] = None, **options: Unpack[RequestOptions]
423
+ ) -> WebhookEndpoint:
424
+ """Adds an endpoint. The answer's ``secret`` is shown only this once."""
425
+ body = {"url": url, **({"description": description} if description is not None else {})}
426
+ # Not retried after a network failure: it could add the endpoint twice.
427
+ return await self._client._request("POST", "/webhook_endpoints", body=body, retry="unsafe", options=options)
428
+
429
+ async def list(self, **options: Unpack[RequestOptions]) -> WebhookEndpointList:
430
+ return await self._client._request("GET", "/webhook_endpoints", options=options)
431
+
432
+ async def delete(self, endpoint_id: str, **options: Unpack[RequestOptions]) -> None:
433
+ await self._client._request("DELETE", f"/webhook_endpoints/{_base.quote(endpoint_id)}", options=options)
434
+
435
+ async def send_test(self, endpoint_id: str, **options: Unpack[RequestOptions]) -> None:
436
+ """Sends a ``webhook.test`` event to the endpoint."""
437
+ await self._client._request(
438
+ "POST", f"/webhook_endpoints/{_base.quote(endpoint_id)}/test", retry="unsafe", options=options
439
+ )
440
+
441
+ async def deliveries(
442
+ self, endpoint_id: str, *, limit: Optional[int] = None, **options: Unpack[RequestOptions]
443
+ ) -> WebhookDeliveryList:
444
+ """Recent deliveries to the endpoint, with what it answered."""
445
+ return await self._client._request(
446
+ "GET", f"/webhook_endpoints/{_base.quote(endpoint_id)}/deliveries", params={"limit": limit}, options=options
447
+ )
448
+
449
+ async def resend_delivery(
450
+ self, endpoint_id: str, delivery_id: str, **options: Unpack[RequestOptions]
451
+ ) -> WebhookDelivery:
452
+ """Sends a past delivery's event to the endpoint again, now. It keeps its event id,
453
+ so a receiver that skips repeats by event id skips it too."""
454
+ return await self._client._request(
455
+ "POST",
456
+ f"/webhook_endpoints/{_base.quote(endpoint_id)}/deliveries/{_base.quote(delivery_id)}/resend",
457
+ retry="unsafe",
458
+ options=options,
459
+ )
460
+
461
+
462
+ class AsyncLanguagesResource:
463
+ def __init__(self, client: AsyncReedapt) -> None:
464
+ self._client = client
465
+
466
+ async def list(self, **options: Unpack[RequestOptions]) -> Languages:
467
+ """The codes this key's plan can dub into right now."""
468
+ return await self._client._request("GET", "/languages", options=options)
469
+
470
+
471
+ class AsyncUsageResource:
472
+ def __init__(self, client: AsyncReedapt) -> None:
473
+ self._client = client
474
+
475
+ async def retrieve(self, **options: Unpack[RequestOptions]) -> Usage:
476
+ """The plan, the kind of key and the minutes left."""
477
+ return await self._client._request("GET", "/usage", options=options)
478
+
479
+ async def report(self, *, month: Optional[str] = None, **options: Unpack[RequestOptions]) -> UsageReport:
480
+ """A month (``YYYY-MM``, UTC; default this month) of minutes and dubs, per key and per language."""
481
+ return await self._client._request("GET", "/usage/report", params={"month": month}, options=options)