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