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/_common.py ADDED
@@ -0,0 +1,293 @@
1
+ """Logic shared by the sync and async clients: retry rules, error mapping, filenames, file sources."""
2
+ from __future__ import annotations
3
+
4
+ import email.utils
5
+ import io
6
+ import json
7
+ import os
8
+ import platform
9
+ import random
10
+ import time
11
+ import unicodedata
12
+ from dataclasses import dataclass
13
+ from datetime import datetime, timezone
14
+ from typing import Any, Callable, Dict, Iterator, Mapping, NamedTuple, Optional, Union
15
+
16
+ from ._error_codes import ERROR_CLASS_BY_CODE
17
+ from ._exceptions import APIError, category_for_status
18
+ from ._version import __version__
19
+
20
+ DEFAULT_BASE_URL = "https://api.scriptivox.com"
21
+ DEFAULT_TIMEOUT = 60.0
22
+ DEFAULT_MAX_RETRIES = 2
23
+ #: A Retry-After longer than this is not waited out automatically; the error is raised instead.
24
+ MAX_RETRY_AFTER = 60.0
25
+ DEFAULT_WAIT_TIMEOUT = 2 * 60 * 60.0
26
+ DEFAULT_POLL_INTERVAL = 2.0
27
+ DEFAULT_MAX_POLL_INTERVAL = 10.0
28
+ #: Per socket operation during the file upload: no byte moving for this long ends that try.
29
+ UPLOAD_IDLE_TIMEOUT = 120.0
30
+ CHUNK = 1024 * 1024
31
+ MAX_FILENAME = 255
32
+ TRANSCRIPTION_OPTION_KEYS = ("language", "diarize", "speaker_count", "align", "webhook_url")
33
+
34
+
35
+ class UploadProgress(NamedTuple):
36
+ """Passed to ``on_progress`` as the file's bytes are sent."""
37
+
38
+ loaded: int
39
+ total: int
40
+ #: 0–100.
41
+ percent: float
42
+
43
+
44
+ FileInput = Union[str, "os.PathLike[str]", bytes, bytearray, memoryview, io.IOBase, Any]
45
+ ProgressCallback = Callable[[UploadProgress], None]
46
+
47
+
48
+ def normalize_base_url(url: str) -> str:
49
+ url = url.rstrip("/")
50
+ return url[: -len("/v1")] if url.endswith("/v1") else url
51
+
52
+
53
+ def user_agent() -> str:
54
+ try:
55
+ import httpx
56
+
57
+ httpx_version = httpx.__version__
58
+ except Exception: # pragma: no cover
59
+ httpx_version = "unknown"
60
+ return f"scriptivox-python/{__version__} python/{platform.python_version()} httpx/{httpx_version}"
61
+
62
+
63
+ def parse_retry_after(value: Optional[str]) -> Optional[float]:
64
+ """Retry-After as seconds: delta-seconds or an HTTP date."""
65
+ if not value:
66
+ return None
67
+ value = value.strip()
68
+ try:
69
+ return max(0.0, float(value))
70
+ except ValueError:
71
+ pass
72
+ try:
73
+ when = email.utils.parsedate_to_datetime(value)
74
+ except (TypeError, ValueError):
75
+ return None
76
+ if when.tzinfo is None:
77
+ when = when.replace(tzinfo=timezone.utc)
78
+ return max(0.0, (when - datetime.now(timezone.utc)).total_seconds())
79
+
80
+
81
+ def backoff(attempt: int) -> float:
82
+ """Exponential backoff with jitter: 0.5 s, 1 s, 2 s, … up to 8 s, ±25 %."""
83
+ return float(min(0.5 * 2.0**attempt, 8.0) * (0.75 + random.random() * 0.5))
84
+
85
+
86
+ def error_from_response(status: int, text: str, headers: Mapping[str, str]) -> APIError:
87
+ """The typed error for an error answer. Handles a non-JSON body (e.g. an HTML 413 from the network layer)."""
88
+ lower = {k.lower(): v for k, v in headers.items()}
89
+ try:
90
+ parsed = json.loads(text) if text else None
91
+ except ValueError:
92
+ parsed = None
93
+ error = parsed.get("error") if isinstance(parsed, dict) and isinstance(parsed.get("error"), dict) else None
94
+ code = error.get("code") if error and isinstance(error.get("code"), str) else "UNKNOWN_ERROR"
95
+ snippet = " ".join(text.split())[:200]
96
+ message = error.get("message") if error and isinstance(error.get("message"), str) else f"HTTP {status}" + (f": {snippet}" if snippet else "")
97
+ request_id = (error.get("request_id") if error and isinstance(error.get("request_id"), str) else None) or lower.get("x-request-id")
98
+ retry_after = parse_retry_after(lower.get("retry-after"))
99
+ if retry_after is None and isinstance(parsed, dict) and isinstance(parsed.get("retry_after"), (int, float)):
100
+ retry_after = float(parsed["retry_after"])
101
+ cls = ERROR_CLASS_BY_CODE.get(code) or category_for_status(status)
102
+ return cls(
103
+ status=status,
104
+ code=code,
105
+ message=message,
106
+ request_id=request_id,
107
+ docs_url=error.get("docs_url") if error and isinstance(error.get("docs_url"), str) else None,
108
+ retry_after=retry_after,
109
+ error=error,
110
+ headers=lower,
111
+ )
112
+
113
+
114
+ def retry_delay_for(err: APIError, idempotent: bool, attempt: int) -> Optional[float]:
115
+ """Whether an error answer is worth another try, and after how many seconds.
116
+
117
+ - 429 or 503 with Retry-After (rate limit, maintenance): refused before any work, so retried whatever
118
+ the method, after Retry-After (up to MAX_RETRY_AFTER). A 429 without Retry-After is the
119
+ jobs-in-progress cap: retrying at once cannot help.
120
+ - 409 IDEMPOTENCY_KEY_LOCKED: the first request with this key is still running; retry shortly.
121
+ - 408 and other 5xx: only when the request is safe to repeat.
122
+ """
123
+ ra = err.retry_after
124
+ if err.status in (429, 503):
125
+ if ra is not None:
126
+ return ra if ra <= MAX_RETRY_AFTER else None
127
+ if err.status == 429:
128
+ return None
129
+ if err.code == "IDEMPOTENCY_KEY_LOCKED":
130
+ return ra if ra is not None and ra <= MAX_RETRY_AFTER else backoff(attempt)
131
+ if idempotent and (err.status == 408 or err.status >= 500):
132
+ return ra if ra is not None and ra <= MAX_RETRY_AFTER else backoff(attempt)
133
+ return None
134
+
135
+
136
+ def query_value(value: Any) -> str:
137
+ if isinstance(value, bool):
138
+ return "true" if value else "false"
139
+ if isinstance(value, datetime):
140
+ if value.tzinfo is None:
141
+ value = value.replace(tzinfo=timezone.utc)
142
+ return str(value.astimezone(timezone.utc).isoformat()).replace("+00:00", "Z")
143
+ return str(value)
144
+
145
+
146
+ def build_query(params: Mapping[str, Any]) -> Dict[str, str]:
147
+ return {k: query_value(v) for k, v in params.items() if v is not None}
148
+
149
+
150
+ def safe_filename(name: str) -> str:
151
+ """The filename the API accepts: printable ASCII, no path separators, none of < > " ' `, at most
152
+ 255 characters, with an extension. Accents are dropped (é → e), anything else becomes "_"."""
153
+ base = name.replace("\\", "/").rsplit("/", 1)[-1]
154
+ decomposed = unicodedata.normalize("NFKD", base)
155
+ stripped = "".join(c for c in decomposed if not unicodedata.combining(c))
156
+ safe = "".join(c if 0x20 <= ord(c) <= 0x7E and c not in '<>"\'`' else "_" for c in stripped).strip()
157
+ dot = safe.rfind(".")
158
+ if dot <= 0 or dot == len(safe) - 1:
159
+ raise ValueError(f'The filename "{name}" needs an extension such as .mp3 or .mp4: pass filename=.')
160
+ if len(safe) > MAX_FILENAME:
161
+ ext = safe[dot:]
162
+ safe = safe[: MAX_FILENAME - len(ext)] + ext
163
+ return safe
164
+
165
+
166
+ @dataclass
167
+ class FileSource:
168
+ name: Optional[str]
169
+ size: int
170
+ repeatable: bool
171
+ _open: Callable[[], Iterator[bytes]]
172
+ _used: bool = False
173
+
174
+ def chunks(self) -> Iterator[bytes]:
175
+ if not self.repeatable:
176
+ if self._used:
177
+ raise ValueError("A stream can only be uploaded once.")
178
+ self._used = True
179
+ return self._open()
180
+
181
+
182
+ def resolve_source(file: Any, size: Optional[int] = None) -> FileSource:
183
+ """Any accepted input as a sized, (where possible) re-readable byte source."""
184
+ if isinstance(file, (str, os.PathLike)):
185
+ path = os.fspath(file)
186
+ if not os.path.isfile(path):
187
+ raise FileNotFoundError(f"{path} is not a file.")
188
+ file_size = os.path.getsize(path)
189
+
190
+ def read_path() -> Iterator[bytes]:
191
+ with open(path, "rb") as fh:
192
+ while True:
193
+ chunk = fh.read(CHUNK)
194
+ if not chunk:
195
+ return
196
+ yield chunk
197
+
198
+ return FileSource(os.path.basename(path), file_size, True, read_path)
199
+
200
+ if isinstance(file, (bytes, bytearray, memoryview)):
201
+ data = bytes(file)
202
+
203
+ def read_bytes() -> Iterator[bytes]:
204
+ for i in range(0, len(data), CHUNK):
205
+ yield data[i : i + CHUNK]
206
+
207
+ return FileSource(None, len(data), True, read_bytes)
208
+
209
+ if hasattr(file, "read"):
210
+ raw_name = getattr(file, "name", None)
211
+ name = os.path.basename(raw_name) if isinstance(raw_name, str) else None
212
+ seekable = bool(getattr(file, "seekable", lambda: False)())
213
+ start = file.tell() if seekable else 0
214
+ if size is None:
215
+ if not seekable:
216
+ raise ValueError("Uploading a non-seekable stream needs its byte length: pass size=.")
217
+ file.seek(0, io.SEEK_END)
218
+ size = file.tell() - start
219
+ file.seek(start)
220
+
221
+ def read_stream() -> Iterator[bytes]:
222
+ if seekable:
223
+ file.seek(start)
224
+ while True:
225
+ chunk = file.read(CHUNK)
226
+ if not chunk:
227
+ return
228
+ if isinstance(chunk, str):
229
+ raise TypeError("Open the file in binary mode ('rb').")
230
+ yield chunk
231
+
232
+ return FileSource(name, int(size), seekable, read_stream)
233
+
234
+ raise TypeError("file must be a path, bytes, or a binary file object.")
235
+
236
+
237
+ def counted(source: FileSource, on_progress: Optional[ProgressCallback]) -> Iterator[bytes]:
238
+ """The source's chunks, checking the byte count and reporting progress."""
239
+ sent = 0
240
+ for chunk in source.chunks():
241
+ if sent + len(chunk) > source.size:
242
+ raise ValueError(f"The source has more bytes than the size given ({source.size}).")
243
+ yield chunk
244
+ sent += len(chunk)
245
+ if on_progress:
246
+ on_progress(UploadProgress(sent, source.size, round(sent / source.size * 100, 1) if source.size else 100.0))
247
+ if sent != source.size:
248
+ raise ValueError(f"The source ended after {sent} of {source.size} bytes.")
249
+ if source.size == 0 and on_progress:
250
+ on_progress(UploadProgress(0, 0, 100.0))
251
+
252
+
253
+ def transcribe_body(
254
+ url: Optional[str],
255
+ upload_id: Optional[str],
256
+ options: Mapping[str, Any],
257
+ ) -> Dict[str, Any]:
258
+ body: Dict[str, Any] = {k: options[k] for k in TRANSCRIPTION_OPTION_KEYS if options.get(k) is not None}
259
+ if url is not None:
260
+ body["url"] = url
261
+ if upload_id is not None:
262
+ body["upload_id"] = upload_id
263
+ return body
264
+
265
+
266
+ def check_one_source(url: Any, file: Any, upload_id: Any) -> None:
267
+ if sum(x is not None for x in (url, file, upload_id)) != 1:
268
+ raise TypeError("transcribe() needs exactly one of url=, file= or upload_id=.")
269
+
270
+
271
+ def path_id(transcription_id: str) -> str:
272
+ if not isinstance(transcription_id, str) or not transcription_id:
273
+ raise TypeError("A transcription id is required.")
274
+ from urllib.parse import quote
275
+
276
+ return quote(transcription_id, safe="")
277
+
278
+
279
+ def export_query(fmt: str, options: Mapping[str, Any]) -> Dict[str, str]:
280
+ if fmt == "json":
281
+ return {}
282
+ params: Dict[str, Any] = {"format": fmt}
283
+ for key, value in options.items():
284
+ if value is None:
285
+ continue
286
+ if key == "include_speakers" and isinstance(value, bool):
287
+ value = "true" if value else "false"
288
+ params[key] = value
289
+ return build_query(params)
290
+
291
+
292
+ def monotonic() -> float:
293
+ return time.monotonic()
@@ -0,0 +1,190 @@
1
+ # GENERATED by scripts/generate.py from openapi/openapi.json — do not edit by hand.
2
+ # OpenAPI 1.0.0 · sha256 489f83bddaa9893f
3
+ """One exception class per API ``error.code``, and the codes a job can end with."""
4
+ from __future__ import annotations
5
+
6
+ from typing import Dict, Tuple, Type
7
+
8
+ from ._exceptions import (
9
+ APIError,
10
+ AuthenticationError,
11
+ BadRequestError,
12
+ ConflictError,
13
+ GoneError,
14
+ InternalServerError,
15
+ MethodNotAllowedError,
16
+ NotFoundError,
17
+ PayloadTooLargeError,
18
+ PaymentRequiredError,
19
+ PermissionDeniedError,
20
+ RateLimitError,
21
+ ServiceUnavailableError,
22
+ UnprocessableEntityError,
23
+ UnsupportedMediaTypeError,
24
+ )
25
+
26
+ #: Every ``error.code`` a request can answer with (the API may add more; unknown codes map by HTTP status).
27
+ API_ERROR_CODES: Tuple[str, ...] = ('INVALID_REQUEST', 'INVALID_FILENAME', 'INVALID_MEDIA_FORMAT', 'FILE_NOT_UPLOADED', 'FILE_TOO_LARGE', 'UPLOAD_ALREADY_USED', 'UPLOAD_EXPIRED', 'INVALID_API_KEY', 'API_KEY_REVOKED', 'INSUFFICIENT_BALANCE', 'ZERO_BALANCE', 'UPLOAD_NOT_FOUND', 'TRANSCRIPTION_NOT_FOUND', 'NOT_FOUND', 'FORBIDDEN', 'METHOD_NOT_ALLOWED', 'CONFLICT', 'PAYLOAD_TOO_LARGE', 'UNSUPPORTED_MEDIA_TYPE', 'IDEMPOTENCY_KEY_LOCKED', 'IDEMPOTENCY_KEY_CONFLICT', 'RATE_LIMIT_EXCEEDED', 'INTERNAL_ERROR', 'MAINTENANCE', 'ENDPOINT_REMOVED', 'ISSUER_UNCONFIGURED')
28
+
29
+ #: The HTTP status each code is sent with.
30
+ HTTP_STATUS_BY_CODE: Dict[str, int] = {"INVALID_REQUEST": 400, "INVALID_FILENAME": 400, "INVALID_MEDIA_FORMAT": 400, "FILE_NOT_UPLOADED": 400, "FILE_TOO_LARGE": 400, "UPLOAD_ALREADY_USED": 400, "UPLOAD_EXPIRED": 400, "INVALID_API_KEY": 401, "API_KEY_REVOKED": 401, "INSUFFICIENT_BALANCE": 402, "ZERO_BALANCE": 402, "UPLOAD_NOT_FOUND": 404, "TRANSCRIPTION_NOT_FOUND": 404, "NOT_FOUND": 404, "FORBIDDEN": 403, "METHOD_NOT_ALLOWED": 405, "CONFLICT": 409, "PAYLOAD_TOO_LARGE": 413, "UNSUPPORTED_MEDIA_TYPE": 415, "IDEMPOTENCY_KEY_LOCKED": 409, "IDEMPOTENCY_KEY_CONFLICT": 422, "RATE_LIMIT_EXCEEDED": 429, "INTERNAL_ERROR": 500, "MAINTENANCE": 503, "ENDPOINT_REMOVED": 410, "ISSUER_UNCONFIGURED": 503}
31
+
32
+ # Codes a job can end with (status "failed", error.code). They never come back on the request itself:
33
+ # wait() raises them as TranscriptionFailedError.
34
+ # URL_NOT_ACCESSIBLE: URL flow only. Covers several non-fetchable cases: the URL returned 4xx/5xx; DNS didn't resolve; the connection was refused; the response was an HTML page instead of media (login/preview/folder/expired-link); the share requires sign-in (e.g. OneDrive `1drv.ms/v/c/…` SharePoint Photos shares); or the server returned a 200 with an empty body. The `message` field names the specific cause when detectable.
35
+ # DOWNLOAD_FAILED: URL flow only: the download started but was interrupted.
36
+ # INVALID_MEDIA_FORMAT: The file downloaded but could not be read: not actually audio/video, no audio track, or **shorter than the 1-second minimum** (message names the measured duration, e.g. `"Audio too short (0.10s). Minimum duration is 1 second."`).
37
+ # FILE_TOO_LARGE: The file is larger than 5 GB. For a URL this is only known once the download reaches the limit.
38
+ # DURATION_TOO_LONG: Audio exceeds the 10-hour limit (only known after probing duration).
39
+ # INSUFFICIENT_BALANCE: Once the length was measured, the job cost more than your available balance. Nothing was charged. The `message` names the amount needed; add funds and submit again.
40
+ # PROCESSING_ERROR: Transcription failed after all retries.
41
+ # CREATED_TIMEOUT: Job sat in `created` for more than 30 minutes: the validation step never started.
42
+ # DOWNLOAD_TIMEOUT: Job sat in `downloading` for more than 15 minutes: the file download stalled.
43
+ # PROCESSING_TIMEOUT: Job sat in `processing` for more than 45 minutes without a result.
44
+ # BILLING_ERROR: Internal accounting issue while finalizing the charge. Your balance was **not** debited and the transcript was not delivered. Safe to retry.
45
+ # INTERNAL_ERROR: A server-side step (e.g. queueing the job to our processing layer) failed after we accepted the request. Safe to retry.
46
+ # CANCELLED: The transcription was cancelled by the customer via `POST /v1/transcribe/{id}/cancel`. You are not charged.
47
+ TRANSCRIPTION_ERROR_CODES: Tuple[str, ...] = ('URL_NOT_ACCESSIBLE', 'DOWNLOAD_FAILED', 'INVALID_MEDIA_FORMAT', 'FILE_TOO_LARGE', 'DURATION_TOO_LONG', 'INSUFFICIENT_BALANCE', 'PROCESSING_ERROR', 'CREATED_TIMEOUT', 'DOWNLOAD_TIMEOUT', 'PROCESSING_TIMEOUT', 'BILLING_ERROR', 'INTERNAL_ERROR', 'CANCELLED')
48
+
49
+
50
+ class InvalidRequestError(BadRequestError):
51
+ """``INVALID_REQUEST`` (HTTP 400). Malformed request body or missing required fields"""
52
+
53
+
54
+ class InvalidFilenameError(BadRequestError):
55
+ """``INVALID_FILENAME`` (HTTP 400). Filename is missing, too long, contains invalid characters, or has no extension"""
56
+
57
+
58
+ class InvalidMediaFormatError(BadRequestError):
59
+ """``INVALID_MEDIA_FORMAT`` (HTTP 400). Unsupported file extension at upload time (e.g. `.txt`, `.pdf`). Also returned later if the contents turn out not to be readable audio or video."""
60
+
61
+
62
+ class FileNotUploadedError(BadRequestError):
63
+ """``FILE_NOT_UPLOADED`` (HTTP 400). File not found at the upload URL"""
64
+
65
+
66
+ class FileTooLargeError(BadRequestError):
67
+ """``FILE_TOO_LARGE`` (HTTP 400). File exceeds 5 GB limit"""
68
+
69
+
70
+ class UploadAlreadyUsedError(BadRequestError):
71
+ """``UPLOAD_ALREADY_USED`` (HTTP 400). Upload already used for a transcription"""
72
+
73
+
74
+ class UploadExpiredError(BadRequestError):
75
+ """``UPLOAD_EXPIRED`` (HTTP 400). Upload URL expired (1 hour TTL)"""
76
+
77
+
78
+ class InvalidApiKeyError(AuthenticationError):
79
+ """``INVALID_API_KEY`` (HTTP 401). Invalid or missing API key"""
80
+
81
+
82
+ class ApiKeyRevokedError(AuthenticationError):
83
+ """``API_KEY_REVOKED`` (HTTP 401). API key has been revoked"""
84
+
85
+
86
+ class InsufficientBalanceError(PaymentRequiredError):
87
+ """``INSUFFICIENT_BALANCE`` (HTTP 402). Not enough balance for this transcription"""
88
+
89
+
90
+ class ZeroBalanceError(PaymentRequiredError):
91
+ """``ZERO_BALANCE`` (HTTP 402). Balance is $0 or below: deposit required"""
92
+
93
+
94
+ class UploadNotFoundError(NotFoundError):
95
+ """``UPLOAD_NOT_FOUND`` (HTTP 404). Upload ID does not exist"""
96
+
97
+
98
+ class TranscriptionNotFoundError(NotFoundError):
99
+ """``TRANSCRIPTION_NOT_FOUND`` (HTTP 404). Transcription ID does not exist"""
100
+
101
+
102
+ class ForbiddenError(PermissionDeniedError):
103
+ """``FORBIDDEN`` (HTTP 403). Request rejected upstream (e.g. by the network layer) before it reached the API."""
104
+
105
+
106
+ class IdempotencyKeyLockedError(ConflictError):
107
+ """``IDEMPOTENCY_KEY_LOCKED`` (HTTP 409). Another request with the same `Idempotency-Key` is mid-flight. Retry after a few seconds (`Retry-After` header sent)."""
108
+
109
+
110
+ class IdempotencyKeyConflictError(UnprocessableEntityError):
111
+ """``IDEMPOTENCY_KEY_CONFLICT`` (HTTP 422). `Idempotency-Key` reused with a different request body. Use a fresh key for a different operation."""
112
+
113
+
114
+ class RateLimitExceededError(RateLimitError):
115
+ """``RATE_LIMIT_EXCEEDED`` (HTTP 429). Too many requests (see Rate Limits), or 25 jobs already in progress on POST /v1/transcribe"""
116
+
117
+
118
+ class InternalError(InternalServerError):
119
+ """``INTERNAL_ERROR`` (HTTP 500). Server error"""
120
+
121
+
122
+ class MaintenanceError(ServiceUnavailableError):
123
+ """``MAINTENANCE`` (HTTP 503). The API is briefly paused for maintenance. Nothing was created or charged. Retry after the number of seconds in the `Retry-After` header."""
124
+
125
+
126
+ class EndpointRemovedError(GoneError):
127
+ """``ENDPOINT_REMOVED`` (HTTP 410). This endpoint has been removed. The `message` says what to use instead (for `POST /v1/accounts`: create the account at https://www.scriptivox.com/signup)."""
128
+
129
+
130
+ class IssuerUnconfiguredError(ServiceUnavailableError):
131
+ """``ISSUER_UNCONFIGURED`` (HTTP 503). OAuth sign-in is not configured on this deployment."""
132
+
133
+
134
+ #: The class raised for each code.
135
+ ERROR_CLASS_BY_CODE: Dict[str, Type[APIError]] = {
136
+ "INVALID_REQUEST": InvalidRequestError,
137
+ "INVALID_FILENAME": InvalidFilenameError,
138
+ "INVALID_MEDIA_FORMAT": InvalidMediaFormatError,
139
+ "FILE_NOT_UPLOADED": FileNotUploadedError,
140
+ "FILE_TOO_LARGE": FileTooLargeError,
141
+ "UPLOAD_ALREADY_USED": UploadAlreadyUsedError,
142
+ "UPLOAD_EXPIRED": UploadExpiredError,
143
+ "INVALID_API_KEY": InvalidApiKeyError,
144
+ "API_KEY_REVOKED": ApiKeyRevokedError,
145
+ "INSUFFICIENT_BALANCE": InsufficientBalanceError,
146
+ "ZERO_BALANCE": ZeroBalanceError,
147
+ "UPLOAD_NOT_FOUND": UploadNotFoundError,
148
+ "TRANSCRIPTION_NOT_FOUND": TranscriptionNotFoundError,
149
+ "NOT_FOUND": NotFoundError,
150
+ "FORBIDDEN": ForbiddenError,
151
+ "METHOD_NOT_ALLOWED": MethodNotAllowedError,
152
+ "CONFLICT": ConflictError,
153
+ "PAYLOAD_TOO_LARGE": PayloadTooLargeError,
154
+ "UNSUPPORTED_MEDIA_TYPE": UnsupportedMediaTypeError,
155
+ "IDEMPOTENCY_KEY_LOCKED": IdempotencyKeyLockedError,
156
+ "IDEMPOTENCY_KEY_CONFLICT": IdempotencyKeyConflictError,
157
+ "RATE_LIMIT_EXCEEDED": RateLimitExceededError,
158
+ "INTERNAL_ERROR": InternalError,
159
+ "MAINTENANCE": MaintenanceError,
160
+ "ENDPOINT_REMOVED": EndpointRemovedError,
161
+ "ISSUER_UNCONFIGURED": IssuerUnconfiguredError,
162
+ }
163
+
164
+ __all__ = [
165
+ "API_ERROR_CODES",
166
+ "HTTP_STATUS_BY_CODE",
167
+ "TRANSCRIPTION_ERROR_CODES",
168
+ "ERROR_CLASS_BY_CODE",
169
+ "InvalidRequestError",
170
+ "InvalidFilenameError",
171
+ "InvalidMediaFormatError",
172
+ "FileNotUploadedError",
173
+ "FileTooLargeError",
174
+ "UploadAlreadyUsedError",
175
+ "UploadExpiredError",
176
+ "InvalidApiKeyError",
177
+ "ApiKeyRevokedError",
178
+ "InsufficientBalanceError",
179
+ "ZeroBalanceError",
180
+ "UploadNotFoundError",
181
+ "TranscriptionNotFoundError",
182
+ "ForbiddenError",
183
+ "IdempotencyKeyLockedError",
184
+ "IdempotencyKeyConflictError",
185
+ "RateLimitExceededError",
186
+ "InternalError",
187
+ "MaintenanceError",
188
+ "EndpointRemovedError",
189
+ "IssuerUnconfiguredError",
190
+ ]
@@ -0,0 +1,196 @@
1
+ """Exceptions. Everything the SDK raises extends ScriptivoxError.
2
+
3
+ ScriptivoxError
4
+ ├── APIError the API answered with an error (status, code, request_id)
5
+ │ ├── BadRequestError (400) ├── InvalidRequestError, InvalidFilenameError, ... (one per error.code,
6
+ │ ├── AuthenticationError (401) │ generated in _error_codes.py from the OpenAPI document)
7
+ │ ├── PaymentRequiredError (402) ├── InsufficientBalanceError, ZeroBalanceError
8
+ │ ├── ... one class per HTTP status the API uses
9
+ │ └── RateLimitError (429) └── RateLimitExceededError
10
+ ├── APIConnectionError no answer: DNS, refused, reset (APITimeoutError: no answer in time)
11
+ ├── UploadFailedError the file PUT to the presigned upload URL failed
12
+ ├── TranscriptionFailedError wait(): the job ended with status "failed" (code = the job's error.code)
13
+ ├── WaitTimeoutError wait(): the job was still running when the timeout passed
14
+ └── WebhookVerificationError verify_webhook(): bad signature, old timestamp, missing header
15
+ """
16
+ from __future__ import annotations
17
+
18
+ from typing import Any, Dict, Mapping, Optional, Type
19
+
20
+
21
+ class ScriptivoxError(Exception):
22
+ """Base class of every error the SDK raises."""
23
+
24
+
25
+ class APIError(ScriptivoxError):
26
+ """The API answered with an HTTP error. Branch on ``code`` (stable) or the class; never on the message."""
27
+
28
+ def __init__(
29
+ self,
30
+ *,
31
+ status: int,
32
+ code: str,
33
+ message: str,
34
+ request_id: Optional[str] = None,
35
+ docs_url: Optional[str] = None,
36
+ retry_after: Optional[float] = None,
37
+ error: Optional[Dict[str, Any]] = None,
38
+ headers: Optional[Mapping[str, str]] = None,
39
+ ) -> None:
40
+ ref = ", ".join(x for x in (code, f"request {request_id}" if request_id else None) if x)
41
+ super().__init__(f"{message} [{ref}]")
42
+ #: HTTP status.
43
+ self.status = status
44
+ #: The API's machine-readable ``error.code``, e.g. "ZERO_BALANCE"; "UNKNOWN_ERROR" when the body had none.
45
+ self.code = code
46
+ #: The server's message (without the bracketed code and request id).
47
+ self.message = message
48
+ #: ``X-Request-Id`` / ``error.request_id`` (``req_`` + 16 hex). Quote it to support.
49
+ self.request_id = request_id
50
+ self.docs_url = docs_url
51
+ #: Seconds from the ``Retry-After`` header, when the answer had one.
52
+ self.retry_after = retry_after
53
+ #: The whole ``error`` object (extra fields such as ``in_flight`` and ``limit`` live here).
54
+ self.error: Dict[str, Any] = dict(error or {})
55
+ #: Response headers, lower-case names.
56
+ self.headers: Dict[str, str] = {k.lower(): v for k, v in (headers or {}).items()}
57
+
58
+
59
+ class BadRequestError(APIError):
60
+ pass
61
+
62
+
63
+ class AuthenticationError(APIError):
64
+ pass
65
+
66
+
67
+ class PaymentRequiredError(APIError):
68
+ pass
69
+
70
+
71
+ class PermissionDeniedError(APIError):
72
+ pass
73
+
74
+
75
+ class NotFoundError(APIError):
76
+ pass
77
+
78
+
79
+ class MethodNotAllowedError(APIError):
80
+ pass
81
+
82
+
83
+ class ConflictError(APIError):
84
+ pass
85
+
86
+
87
+ class GoneError(APIError):
88
+ pass
89
+
90
+
91
+ class PayloadTooLargeError(APIError):
92
+ pass
93
+
94
+
95
+ class UnsupportedMediaTypeError(APIError):
96
+ pass
97
+
98
+
99
+ class UnprocessableEntityError(APIError):
100
+ pass
101
+
102
+
103
+ class RateLimitError(APIError):
104
+ pass
105
+
106
+
107
+ class InternalServerError(APIError):
108
+ pass
109
+
110
+
111
+ class ServiceUnavailableError(APIError):
112
+ pass
113
+
114
+
115
+ _CATEGORY_BY_STATUS: Dict[int, Type[APIError]] = {
116
+ 400: BadRequestError,
117
+ 401: AuthenticationError,
118
+ 402: PaymentRequiredError,
119
+ 403: PermissionDeniedError,
120
+ 404: NotFoundError,
121
+ 405: MethodNotAllowedError,
122
+ 409: ConflictError,
123
+ 410: GoneError,
124
+ 413: PayloadTooLargeError,
125
+ 415: UnsupportedMediaTypeError,
126
+ 422: UnprocessableEntityError,
127
+ 429: RateLimitError,
128
+ 503: ServiceUnavailableError,
129
+ }
130
+
131
+
132
+ def category_for_status(status: int) -> Type[APIError]:
133
+ """The category class for an HTTP status, used when a code is not one the SDK knows."""
134
+ if status in _CATEGORY_BY_STATUS:
135
+ return _CATEGORY_BY_STATUS[status]
136
+ return InternalServerError if status >= 500 else APIError
137
+
138
+
139
+ class APIConnectionError(ScriptivoxError):
140
+ """No HTTP answer at all: DNS failure, connection refused or reset."""
141
+
142
+
143
+ class APITimeoutError(APIConnectionError):
144
+ """No answer within the request timeout."""
145
+
146
+
147
+ class UploadFailedError(ScriptivoxError):
148
+ """The PUT of the file bytes to the presigned upload URL failed."""
149
+
150
+ def __init__(self, message: str, *, status: Optional[int] = None, body: Optional[str] = None) -> None:
151
+ super().__init__(message)
152
+ #: HTTP status from the storage server, when it answered.
153
+ self.status = status
154
+ #: The start of the storage server's answer (often XML).
155
+ self.body = body
156
+
157
+
158
+ class TranscriptionFailedError(ScriptivoxError):
159
+ """wait(): the job ended with ``status: "failed"``. Failed jobs cost nothing.
160
+
161
+ ``code`` is the job's ``error.code`` (URL_NOT_ACCESSIBLE, INVALID_MEDIA_FORMAT, CANCELLED, ...);
162
+ ``transcription`` is the job.
163
+ """
164
+
165
+ def __init__(self, transcription: Mapping[str, Any]) -> None:
166
+ error = transcription.get("error") or {}
167
+ self.code: str = error.get("code") or "UNKNOWN_ERROR"
168
+ self.transcription_id: str = transcription.get("id", "")
169
+ self.transcription: Dict[str, Any] = dict(transcription)
170
+ message = error.get("message") or "The transcription failed."
171
+ super().__init__(f"Transcription {self.transcription_id} failed: {message} [{self.code}]")
172
+
173
+
174
+ class WaitTimeoutError(ScriptivoxError):
175
+ """wait(): the timeout passed while the job was still running. The job carries on; wait again later."""
176
+
177
+ def __init__(self, transcription: Mapping[str, Any], timeout: float) -> None:
178
+ self.transcription_id: str = transcription.get("id", "")
179
+ #: The last status seen.
180
+ self.transcription: Dict[str, Any] = dict(transcription)
181
+ super().__init__(
182
+ f'Transcription {self.transcription_id} is still "{transcription.get("status")}" after {round(timeout)} s. '
183
+ "It carries on running; call wait() again or get() later."
184
+ )
185
+
186
+
187
+ class WebhookVerificationError(ScriptivoxError):
188
+ """verify_webhook(): not a genuine, fresh Scriptivox webhook. Answer such a request with 400.
189
+
190
+ ``reason`` is one of: missing_header, invalid_timestamp, timestamp_outside_tolerance,
191
+ signature_mismatch, invalid_json.
192
+ """
193
+
194
+ def __init__(self, reason: str, message: str) -> None:
195
+ super().__init__(message)
196
+ self.reason = reason