scriptivox 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.
scriptivox/__init__.py ADDED
@@ -0,0 +1,145 @@
1
+ """Official Python SDK for the Scriptivox transcription API.
2
+
3
+ from scriptivox import Scriptivox
4
+
5
+ client = Scriptivox() # reads SCRIPTIVOX_API_KEY
6
+ job = client.transcribe(file="meeting.mp3", language="en")
7
+ done = client.wait(job["id"])
8
+ print(done["result"]["full_transcript"])
9
+ """
10
+ from ._async_client import AsyncScriptivox
11
+ from ._client import Scriptivox, UploadResult
12
+ from ._common import DEFAULT_BASE_URL, UploadProgress, safe_filename
13
+ from ._error_codes import * # noqa: F401,F403 (one exception class per error.code)
14
+ from ._error_codes import API_ERROR_CODES, ERROR_CLASS_BY_CODE, HTTP_STATUS_BY_CODE, TRANSCRIPTION_ERROR_CODES
15
+ from ._error_codes import __all__ as _code_classes
16
+ from ._exceptions import (
17
+ APIConnectionError,
18
+ APIError,
19
+ APITimeoutError,
20
+ AuthenticationError,
21
+ BadRequestError,
22
+ ConflictError,
23
+ GoneError,
24
+ InternalServerError,
25
+ MethodNotAllowedError,
26
+ NotFoundError,
27
+ PayloadTooLargeError,
28
+ PaymentRequiredError,
29
+ PermissionDeniedError,
30
+ RateLimitError,
31
+ ScriptivoxError,
32
+ ServiceUnavailableError,
33
+ TranscriptionFailedError,
34
+ UnprocessableEntityError,
35
+ UnsupportedMediaTypeError,
36
+ UploadFailedError,
37
+ WaitTimeoutError,
38
+ WebhookVerificationError,
39
+ )
40
+ from ._types import (
41
+ AutoTopup,
42
+ Balance,
43
+ FeedbackRequest,
44
+ FeedbackResult,
45
+ Transcription,
46
+ TranscriptionCancelled,
47
+ TranscriptionCreated,
48
+ TranscriptionError,
49
+ TranscriptionPage,
50
+ TranscriptionResult,
51
+ TranscriptionStatus,
52
+ TranscriptionSummary,
53
+ Upload,
54
+ Utterance,
55
+ WebhookSendResult,
56
+ WebhookTest,
57
+ WebhookTranscriptionCompleted,
58
+ WebhookTranscriptionDownloading,
59
+ WebhookTranscriptionFailed,
60
+ WebhookTranscriptionProcessing,
61
+ Word,
62
+ )
63
+ from ._version import __version__
64
+ from ._webhooks import (
65
+ ATTEMPT_HEADER,
66
+ DEFAULT_TOLERANCE,
67
+ DELIVERY_HEADER,
68
+ SIGNATURE_HEADER,
69
+ TIMESTAMP_HEADER,
70
+ WebhookEvent,
71
+ compute_signature,
72
+ verify_webhook,
73
+ )
74
+
75
+ __all__ = [
76
+ "Scriptivox",
77
+ "AsyncScriptivox",
78
+ "verify_webhook",
79
+ "compute_signature",
80
+ "safe_filename",
81
+ "UploadProgress",
82
+ "UploadResult",
83
+ "DEFAULT_BASE_URL",
84
+ "DEFAULT_TOLERANCE",
85
+ "SIGNATURE_HEADER",
86
+ "TIMESTAMP_HEADER",
87
+ "DELIVERY_HEADER",
88
+ "ATTEMPT_HEADER",
89
+ "WebhookEvent",
90
+ "ScriptivoxError",
91
+ "APIError",
92
+ "APIConnectionError",
93
+ "APITimeoutError",
94
+ "BadRequestError",
95
+ "AuthenticationError",
96
+ "PaymentRequiredError",
97
+ "PermissionDeniedError",
98
+ "NotFoundError",
99
+ "MethodNotAllowedError",
100
+ "ConflictError",
101
+ "GoneError",
102
+ "PayloadTooLargeError",
103
+ "UnsupportedMediaTypeError",
104
+ "UnprocessableEntityError",
105
+ "RateLimitError",
106
+ "InternalServerError",
107
+ "ServiceUnavailableError",
108
+ "UploadFailedError",
109
+ "TranscriptionFailedError",
110
+ "WaitTimeoutError",
111
+ "WebhookVerificationError",
112
+ "AutoTopup",
113
+ "Balance",
114
+ "FeedbackRequest",
115
+ "FeedbackResult",
116
+ "Transcription",
117
+ "TranscriptionCancelled",
118
+ "TranscriptionCreated",
119
+ "TranscriptionError",
120
+ "TranscriptionPage",
121
+ "TranscriptionResult",
122
+ "TranscriptionStatus",
123
+ "TranscriptionSummary",
124
+ "Upload",
125
+ "Utterance",
126
+ "WebhookSendResult",
127
+ "WebhookTest",
128
+ "WebhookTranscriptionCompleted",
129
+ "WebhookTranscriptionDownloading",
130
+ "WebhookTranscriptionFailed",
131
+ "WebhookTranscriptionProcessing",
132
+ "Word",
133
+ "__version__",
134
+ *_code_classes,
135
+ ]
136
+
137
+ # Tracebacks and reprs name the public module (scriptivox.ZeroBalanceError), not the private file it lives in.
138
+ for _name in __all__:
139
+ _obj = globals().get(_name)
140
+ if isinstance(_obj, type) and getattr(_obj, "__module__", "").startswith("scriptivox._"):
141
+ try:
142
+ _obj.__module__ = __name__
143
+ except (AttributeError, TypeError): # pragma: no cover
144
+ pass
145
+ del _name, _obj
@@ -0,0 +1,428 @@
1
+ """The asyncio client: the same methods as Scriptivox, awaited; list() is an async iterator."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import os
6
+ import uuid
7
+ from typing import Any, AsyncIterator, Dict, List, Mapping, Optional, Union, cast, overload
8
+
9
+ import httpx
10
+
11
+ from . import _common as c
12
+ from ._exceptions import (
13
+ APIConnectionError,
14
+ APIError,
15
+ APITimeoutError,
16
+ RateLimitError,
17
+ ScriptivoxError,
18
+ TranscriptionFailedError,
19
+ UploadFailedError,
20
+ WaitTimeoutError,
21
+ )
22
+ from ._types import (
23
+ Balance,
24
+ FeedbackResult,
25
+ Transcription,
26
+ TranscriptionCancelled,
27
+ TranscriptionCreated,
28
+ TranscriptionPage,
29
+ TranscriptionStatus,
30
+ TranscriptionSummary,
31
+ Upload,
32
+ WebhookSendResult,
33
+ )
34
+ from ._webhooks import HeadersLike, WebhookEvent, verify_webhook
35
+
36
+ from typing import Literal
37
+
38
+ from ._client import _MISSING, UploadResult, _resolve_key
39
+
40
+
41
+ async def _aiter(chunks: Any) -> Any:
42
+ """httpx.AsyncClient streams only async iterables. The file is read in 1 MiB chunks between sends."""
43
+ for chunk in chunks:
44
+ yield chunk
45
+
46
+
47
+ class AsyncScriptivox:
48
+ """The Scriptivox API client for asyncio.
49
+
50
+ >>> async with AsyncScriptivox() as client: # reads SCRIPTIVOX_API_KEY
51
+ ... job = await client.transcribe(file="meeting.mp3", language="en")
52
+ ... done = await client.wait(job["id"])
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ api_key: Optional[str] = None,
58
+ *,
59
+ base_url: Optional[str] = None,
60
+ max_retries: int = c.DEFAULT_MAX_RETRIES,
61
+ timeout: float = c.DEFAULT_TIMEOUT,
62
+ http_client: Optional[httpx.AsyncClient] = None,
63
+ default_headers: Optional[Mapping[str, str]] = None,
64
+ ) -> None:
65
+ self._api_key = _resolve_key(api_key)
66
+ self.base_url = c.normalize_base_url(base_url or os.environ.get("SCRIPTIVOX_BASE_URL") or c.DEFAULT_BASE_URL)
67
+ self.max_retries = max_retries
68
+ self.timeout = timeout
69
+ self._default_headers = dict(default_headers or {})
70
+ self._owns_client = http_client is None
71
+ self._http = http_client or httpx.AsyncClient(follow_redirects=False)
72
+
73
+ # ─── lifecycle ────────────────────────────────────────────────────────────
74
+
75
+ async def aclose(self) -> None:
76
+ if self._owns_client:
77
+ await self._http.aclose()
78
+
79
+ async def __aenter__(self) -> "AsyncScriptivox":
80
+ return self
81
+
82
+ async def __aexit__(self, *exc: Any) -> None:
83
+ await self.aclose()
84
+
85
+ # ─── transport ────────────────────────────────────────────────────────────
86
+
87
+ async def _send(
88
+ self,
89
+ method: str,
90
+ path: str,
91
+ *,
92
+ idempotent: bool,
93
+ query: Optional[Mapping[str, Any]] = None,
94
+ body: Any = _MISSING,
95
+ idempotency_key: Optional[str] = None,
96
+ text: bool = False,
97
+ timeout: Optional[float] = None,
98
+ max_retries: Optional[int] = None,
99
+ ) -> Any:
100
+ retries = self.max_retries if max_retries is None else max_retries
101
+ headers: Dict[str, str] = {
102
+ **self._default_headers,
103
+ "Authorization": self._api_key,
104
+ "Accept": "text/plain, text/vtt, application/json" if text else "application/json",
105
+ "User-Agent": c.user_agent(),
106
+ }
107
+ if idempotency_key:
108
+ headers["Idempotency-Key"] = idempotency_key
109
+ kwargs: Dict[str, Any] = {}
110
+ if body is not _MISSING:
111
+ kwargs["json"] = body
112
+ attempt = 0
113
+ while True:
114
+ try:
115
+ response = await self._http.request(
116
+ method,
117
+ self.base_url + path,
118
+ params=c.build_query(query or {}),
119
+ headers=headers,
120
+ timeout=self.timeout if timeout is None else timeout,
121
+ **kwargs,
122
+ )
123
+ except httpx.TimeoutException as exc:
124
+ err: Exception = APITimeoutError(f"No answer from {method} {path} in time.")
125
+ err.__cause__ = exc
126
+ except httpx.TransportError as exc:
127
+ err = APIConnectionError(f"Could not reach the Scriptivox API ({method} {path}): {exc}")
128
+ err.__cause__ = exc
129
+ else:
130
+ if response.is_success:
131
+ if text:
132
+ return response.text
133
+ if not response.content:
134
+ return None
135
+ try:
136
+ return response.json()
137
+ except ValueError:
138
+ raise APIError(
139
+ status=response.status_code,
140
+ code="INVALID_RESPONSE",
141
+ message=f"The API answered {method} {path} with a body that is not JSON.",
142
+ request_id=response.headers.get("x-request-id"),
143
+ headers=response.headers,
144
+ ) from None
145
+ api_err = c.error_from_response(response.status_code, response.text, response.headers)
146
+ delay = c.retry_delay_for(api_err, idempotent, attempt) if attempt < retries else None
147
+ if delay is None:
148
+ raise api_err
149
+ await asyncio.sleep(delay)
150
+ attempt += 1
151
+ continue
152
+ if idempotent and attempt < retries:
153
+ await asyncio.sleep(c.backoff(attempt))
154
+ attempt += 1
155
+ continue
156
+ raise err
157
+
158
+ async def _put(self, upload_url: str, headers: Mapping[str, str], source: c.FileSource, on_progress: Optional[c.ProgressCallback]) -> None:
159
+ attempt = 0
160
+ while True:
161
+ try:
162
+ response = await self._http.request(
163
+ "PUT",
164
+ upload_url,
165
+ content=_aiter(c.counted(source, on_progress)),
166
+ headers={**headers, "Content-Length": str(source.size), "User-Agent": c.user_agent()},
167
+ timeout=c.UPLOAD_IDLE_TIMEOUT,
168
+ )
169
+ if response.is_success:
170
+ return
171
+ err: Exception = UploadFailedError(
172
+ f"The file upload was refused with HTTP {response.status_code}.",
173
+ status=response.status_code,
174
+ body=response.text[:2048],
175
+ )
176
+ transient = response.status_code >= 500
177
+ except httpx.TimeoutException as exc:
178
+ err = APITimeoutError("The upload stalled.")
179
+ err.__cause__ = exc
180
+ transient = True
181
+ except httpx.TransportError as exc:
182
+ err = APIConnectionError(f"The upload connection failed: {exc}")
183
+ err.__cause__ = exc
184
+ transient = True
185
+ if not transient or not source.repeatable or attempt >= self.max_retries:
186
+ raise err
187
+ await asyncio.sleep(c.backoff(attempt))
188
+ attempt += 1
189
+
190
+ # ─── upload and transcribe ────────────────────────────────────────────────
191
+
192
+ async def upload(
193
+ self,
194
+ file: c.FileInput,
195
+ *,
196
+ filename: Optional[str] = None,
197
+ size: Optional[int] = None,
198
+ on_progress: Optional[c.ProgressCallback] = None,
199
+ idempotency_key: Optional[str] = None,
200
+ ) -> UploadResult:
201
+ """Upload a local file (POST /v1/upload, then a streamed PUT to the presigned URL).
202
+
203
+ ``file`` is a path, bytes, or a binary file object. Large files are streamed, never read
204
+ into memory. Returns the ``upload_id`` for ``transcribe(upload_id=...)``.
205
+ """
206
+ source = c.resolve_source(file, size)
207
+ raw_name = filename or source.name
208
+ if not raw_name:
209
+ raise ValueError('Uploading bytes or a stream needs a filename with an extension: pass filename="audio.mp3".')
210
+ safe = c.safe_filename(raw_name)
211
+ upload = cast(
212
+ Upload,
213
+ await self._send(
214
+ "POST",
215
+ "/v1/upload",
216
+ body={"filename": safe},
217
+ idempotency_key=idempotency_key or str(uuid.uuid4()),
218
+ idempotent=True,
219
+ ),
220
+ )
221
+ if upload.get("method", "PUT").upper() != "PUT":
222
+ raise ScriptivoxError(f"The API asked for an upload method this SDK does not know: {upload.get('method')}.")
223
+ await self._put(upload["upload_url"], upload.get("headers") or {}, source, on_progress)
224
+ return {"upload_id": upload["upload_id"], "filename": safe, "size": source.size}
225
+
226
+ async def transcribe(
227
+ self,
228
+ *,
229
+ url: Optional[str] = None,
230
+ file: Optional[c.FileInput] = None,
231
+ upload_id: Optional[str] = None,
232
+ language: Optional[str] = None,
233
+ diarize: Optional[bool] = None,
234
+ speaker_count: Optional[int] = None,
235
+ align: Optional[bool] = None,
236
+ webhook_url: Optional[str] = None,
237
+ filename: Optional[str] = None,
238
+ size: Optional[int] = None,
239
+ on_upload_progress: Optional[c.ProgressCallback] = None,
240
+ idempotency_key: Optional[str] = None,
241
+ ) -> TranscriptionCreated:
242
+ """Start a transcription from a public ``url``, a local ``file`` (uploaded first) or an earlier
243
+ ``upload_id``. Returns as soon as the job is accepted (status "created"); call wait(id) next.
244
+
245
+ Every call sends an Idempotency-Key (a new UUID unless you pass one), so the SDK's own retries
246
+ never create a second billable job. Omit ``language`` to auto-detect.
247
+ """
248
+ c.check_one_source(url, file, upload_id)
249
+ if file is not None:
250
+ upload_id = (await self.upload(file, filename=filename, size=size, on_progress=on_upload_progress))["upload_id"]
251
+ body = c.transcribe_body(
252
+ url,
253
+ upload_id,
254
+ {"language": language, "diarize": diarize, "speaker_count": speaker_count, "align": align, "webhook_url": webhook_url},
255
+ )
256
+ return cast(
257
+ TranscriptionCreated,
258
+ await self._send("POST", "/v1/transcribe", body=body, idempotency_key=idempotency_key or str(uuid.uuid4()), idempotent=True),
259
+ )
260
+
261
+ # ─── read ─────────────────────────────────────────────────────────────────
262
+
263
+ @overload
264
+ async def get(self, transcription_id: str) -> Transcription: ...
265
+
266
+ @overload
267
+ async def get(self, transcription_id: str, *, format: Literal["json"]) -> Transcription: ...
268
+
269
+ @overload
270
+ async def get(
271
+ self,
272
+ transcription_id: str,
273
+ *,
274
+ format: Literal["srt", "vtt", "text"],
275
+ max_words: Optional[int] = None,
276
+ max_chars: Optional[int] = None,
277
+ max_duration: Optional[int] = None,
278
+ sentence_aware: Optional[bool] = None,
279
+ include_speakers: Union[bool, Literal["auto", "true", "false"], None] = None,
280
+ strip_chars: Optional[str] = None,
281
+ include_timestamps: Optional[bool] = None,
282
+ ) -> str: ...
283
+
284
+ async def get(
285
+ self,
286
+ transcription_id: str,
287
+ *,
288
+ format: str = "json",
289
+ max_words: Optional[int] = None,
290
+ max_chars: Optional[int] = None,
291
+ max_duration: Optional[int] = None,
292
+ sentence_aware: Optional[bool] = None,
293
+ include_speakers: Union[bool, str, None] = None,
294
+ strip_chars: Optional[str] = None,
295
+ include_timestamps: Optional[bool] = None,
296
+ ) -> Union[Transcription, str]:
297
+ """GET /v1/transcribe/{id}: the job (format "json"), or the SRT / VTT / text file as a string."""
298
+ query = c.export_query(
299
+ format,
300
+ {
301
+ "max_words": max_words,
302
+ "max_chars": max_chars,
303
+ "max_duration": max_duration,
304
+ "sentence_aware": sentence_aware,
305
+ "include_speakers": include_speakers,
306
+ "strip_chars": strip_chars,
307
+ "include_timestamps": include_timestamps,
308
+ },
309
+ )
310
+ return cast(
311
+ Union[Transcription, str],
312
+ await self._send("GET", f"/v1/transcribe/{c.path_id(transcription_id)}", query=query, idempotent=True, text=format != "json"),
313
+ )
314
+
315
+ async def wait(
316
+ self,
317
+ transcription_id: str,
318
+ *,
319
+ timeout: float = c.DEFAULT_WAIT_TIMEOUT,
320
+ poll_interval: float = c.DEFAULT_POLL_INTERVAL,
321
+ max_poll_interval: float = c.DEFAULT_MAX_POLL_INTERVAL,
322
+ on_poll: Optional[Any] = None,
323
+ ) -> Transcription:
324
+ """Poll until the job ends and return it completed. A failed job raises TranscriptionFailedError
325
+ (its ``code`` is the job's error.code). The wait between polls starts at 2 s and grows to 10 s;
326
+ a 429 waits out its Retry-After. ``timeout`` is in seconds (default 2 hours)."""
327
+ deadline = c.monotonic() + timeout
328
+ interval = min(poll_interval, max_poll_interval)
329
+ while True:
330
+ try:
331
+ job: Transcription = await self.get(transcription_id)
332
+ except RateLimitError as exc:
333
+ if exc.retry_after is not None and c.monotonic() + exc.retry_after < deadline:
334
+ await asyncio.sleep(exc.retry_after)
335
+ continue
336
+ raise
337
+ if on_poll:
338
+ on_poll(job)
339
+ if job["status"] == "completed":
340
+ return job
341
+ if job["status"] == "failed":
342
+ raise TranscriptionFailedError(job)
343
+ if c.monotonic() + interval > deadline:
344
+ raise WaitTimeoutError(job, timeout)
345
+ await asyncio.sleep(interval)
346
+ interval = min(interval * 1.5, max_poll_interval)
347
+
348
+ async def list_page(
349
+ self,
350
+ *,
351
+ status: Optional[TranscriptionStatus] = None,
352
+ from_: Optional[Any] = None,
353
+ to: Optional[Any] = None,
354
+ limit: Optional[int] = None,
355
+ order: Optional[Literal["desc", "asc"]] = None,
356
+ cursor: Optional[str] = None,
357
+ ) -> TranscriptionPage:
358
+ """One page of GET /v1/transcriptions. Most callers want list(), which follows the cursor."""
359
+ query = {"status": status, "from": from_, "to": to, "limit": limit, "order": order, "cursor": cursor}
360
+ return cast(TranscriptionPage, await self._send("GET", "/v1/transcriptions", query=query, idempotent=True))
361
+
362
+ async def list( # noqa: A003
363
+ self,
364
+ *,
365
+ status: Optional[TranscriptionStatus] = None,
366
+ from_: Optional[Any] = None,
367
+ to: Optional[Any] = None,
368
+ limit: Optional[int] = None,
369
+ order: Optional[Literal["desc", "asc"]] = None,
370
+ cursor: Optional[str] = None,
371
+ ) -> AsyncIterator[TranscriptionSummary]:
372
+ """Every matching transcription, newest first, fetched page by page as you iterate (async for).
373
+ ``from_`` and ``to`` take a datetime or an ISO string. Items omit ``result``; get(id) has it."""
374
+ while True:
375
+ page = await self.list_page(status=status, from_=from_, to=to, limit=limit, order=order, cursor=cursor)
376
+ for item in page["items"]:
377
+ yield item
378
+ cursor = page.get("next_cursor")
379
+ if not cursor:
380
+ return
381
+
382
+ # ─── change ───────────────────────────────────────────────────────────────
383
+
384
+ async def cancel(self, transcription_id: str) -> TranscriptionCancelled:
385
+ """Stop a job before it starts transcribing (created, downloading or pending). You are not charged."""
386
+ return cast(TranscriptionCancelled, await self._send("POST", f"/v1/transcribe/{c.path_id(transcription_id)}/cancel", idempotent=True))
387
+
388
+ async def delete(self, transcription_id: str) -> None:
389
+ """Delete a finished job and its transcript. Twice is fine; a job still running raises ConflictError (cancel it first)."""
390
+ await self._send("DELETE", f"/v1/transcribe/{c.path_id(transcription_id)}", idempotent=True)
391
+
392
+ async def balance(self) -> Balance:
393
+ """Balance, reserved and available amounts in cents, the price per hour and the auto top-up settings."""
394
+ return cast(Balance, await self._send("GET", "/v1/balance", idempotent=True))
395
+
396
+ # ─── webhooks and feedback ────────────────────────────────────────────────
397
+
398
+ async def resend_webhook(self, transcription_id: str) -> WebhookSendResult:
399
+ """Send a finished job's final webhook again now, with the same X-Scriptivox-Delivery id."""
400
+ return cast(WebhookSendResult, await self._send("POST", f"/v1/transcribe/{c.path_id(transcription_id)}/webhook/resend", idempotent=False))
401
+
402
+ async def test_webhook(self, url: Optional[str] = None) -> WebhookSendResult:
403
+ """Send a signed ``webhook.test`` event to ``url`` (or your default webhook URL) and report the answer."""
404
+ return cast(WebhookSendResult, await self._send("POST", "/v1/webhooks/test", body={} if url is None else {"url": url}, idempotent=False))
405
+
406
+ async def feedback(
407
+ self,
408
+ transcription_id: str,
409
+ *,
410
+ rating: Optional[int] = None,
411
+ topics: Optional[List[str]] = None,
412
+ comment: Optional[str] = None,
413
+ their_words: Optional[str] = None,
414
+ problem: Optional[bool] = None,
415
+ what_happened: Optional[str] = None,
416
+ ) -> FeedbackResult:
417
+ """Rate a completed job (1–5) and/or report a problem with any job."""
418
+ body = {
419
+ k: v
420
+ for k, v in {"rating": rating, "topics": topics, "comment": comment, "their_words": their_words, "problem": problem, "what_happened": what_happened}.items()
421
+ if v is not None
422
+ }
423
+ return cast(FeedbackResult, await self._send("POST", f"/v1/transcribe/{c.path_id(transcription_id)}/feedback", body=body, idempotent=False))
424
+
425
+ @staticmethod
426
+ def verify_webhook(raw_body: Union[bytes, str], headers: HeadersLike, secret: str, *, tolerance: int = 300, now: Optional[float] = None) -> WebhookEvent:
427
+ """Same as the module-level verify_webhook()."""
428
+ return verify_webhook(raw_body, headers, secret, tolerance=tolerance, now=now)