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 +145 -0
- scriptivox/_async_client.py +428 -0
- scriptivox/_client.py +437 -0
- scriptivox/_common.py +293 -0
- scriptivox/_error_codes.py +190 -0
- scriptivox/_exceptions.py +196 -0
- scriptivox/_types.py +473 -0
- scriptivox/_version.py +1 -0
- scriptivox/_webhooks.py +108 -0
- scriptivox/py.typed +0 -0
- scriptivox-0.1.0.dist-info/METADATA +235 -0
- scriptivox-0.1.0.dist-info/RECORD +14 -0
- scriptivox-0.1.0.dist-info/WHEEL +4 -0
- scriptivox-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|