tldw-client 0.2.6__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.
tldw_client/__init__.py
ADDED
|
@@ -0,0 +1,1221 @@
|
|
|
1
|
+
"""A dependency-free, scoped-key client for the tldw.sh async API.
|
|
2
|
+
|
|
3
|
+
No server-returned URLs are followed. Waiting never cancels accepted server work.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import io
|
|
9
|
+
import json
|
|
10
|
+
import math
|
|
11
|
+
import re
|
|
12
|
+
import socket
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from decimal import Decimal, InvalidOperation
|
|
17
|
+
from email.utils import parsedate_to_datetime
|
|
18
|
+
from http.client import HTTPConnection, HTTPException, HTTPSConnection
|
|
19
|
+
from urllib.error import HTTPError, URLError
|
|
20
|
+
from urllib.parse import urlencode, urlsplit
|
|
21
|
+
from urllib.request import (
|
|
22
|
+
HTTPHandler,
|
|
23
|
+
HTTPRedirectHandler,
|
|
24
|
+
HTTPSHandler,
|
|
25
|
+
ProxyHandler,
|
|
26
|
+
Request,
|
|
27
|
+
build_opener,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"TldwCanceledError",
|
|
32
|
+
"TldwClient",
|
|
33
|
+
"TldwError",
|
|
34
|
+
"TldwJobError",
|
|
35
|
+
"TldwTimeoutError",
|
|
36
|
+
]
|
|
37
|
+
__version__ = "0.2.6"
|
|
38
|
+
_MAX_BODY = 8 * 1024 * 1024
|
|
39
|
+
_TRANSIENT = {429, 502, 503, 504}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _spending_options(transcription=None, max_credits=None, force_asr=None):
|
|
43
|
+
if transcription is not None and transcription not in ("never", "if_needed"):
|
|
44
|
+
raise ValueError("transcription must be never or if_needed.")
|
|
45
|
+
if force_asr is not None and not isinstance(force_asr, bool):
|
|
46
|
+
raise ValueError("force_asr must be a boolean.")
|
|
47
|
+
if force_asr and transcription != "if_needed":
|
|
48
|
+
raise ValueError("force_asr requires explicit transcription=if_needed.")
|
|
49
|
+
result = {}
|
|
50
|
+
if transcription is not None:
|
|
51
|
+
result["transcription"] = transcription
|
|
52
|
+
if force_asr is not None:
|
|
53
|
+
result["force_asr"] = force_asr
|
|
54
|
+
if max_credits is not None:
|
|
55
|
+
try:
|
|
56
|
+
if isinstance(max_credits, bool):
|
|
57
|
+
raise TypeError
|
|
58
|
+
value = Decimal(str(max_credits))
|
|
59
|
+
if (
|
|
60
|
+
not value.is_finite()
|
|
61
|
+
or not 0 < value <= 1_000_000
|
|
62
|
+
or value != value.quantize(Decimal("0.01"))
|
|
63
|
+
):
|
|
64
|
+
raise ValueError
|
|
65
|
+
except (InvalidOperation, ValueError, TypeError):
|
|
66
|
+
raise ValueError(
|
|
67
|
+
"max_credits must be greater than zero, at most 1000000, with at most two decimal places."
|
|
68
|
+
) from None
|
|
69
|
+
result["max_credits"] = format(value.quantize(Decimal("0.01")), "f")
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_JOB_STATES = {
|
|
74
|
+
"queued",
|
|
75
|
+
"fetching_metadata",
|
|
76
|
+
"fetching_transcript",
|
|
77
|
+
"downloading_audio",
|
|
78
|
+
"transcribing",
|
|
79
|
+
"summarizing",
|
|
80
|
+
"completed",
|
|
81
|
+
"failed",
|
|
82
|
+
"canceled",
|
|
83
|
+
}
|
|
84
|
+
_COMPARISON_STATES = {
|
|
85
|
+
"queued",
|
|
86
|
+
"waiting",
|
|
87
|
+
"processing",
|
|
88
|
+
"completed",
|
|
89
|
+
"failed",
|
|
90
|
+
"canceled",
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TldwError(Exception):
|
|
95
|
+
def __init__(
|
|
96
|
+
self,
|
|
97
|
+
message,
|
|
98
|
+
*,
|
|
99
|
+
code="CLIENT_ERROR",
|
|
100
|
+
status=None,
|
|
101
|
+
request_id=None,
|
|
102
|
+
retry_after_seconds=None,
|
|
103
|
+
job_id=None,
|
|
104
|
+
comparison_id=None,
|
|
105
|
+
details=None,
|
|
106
|
+
):
|
|
107
|
+
super().__init__(message)
|
|
108
|
+
self.code = code
|
|
109
|
+
self.status = status
|
|
110
|
+
self.request_id = request_id
|
|
111
|
+
self.retry_after_seconds = retry_after_seconds
|
|
112
|
+
self.job_id = job_id
|
|
113
|
+
self.comparison_id = comparison_id
|
|
114
|
+
# Redacted API error.details, e.g. {"job_id": ...} or {"required_scope": ...}.
|
|
115
|
+
self.details = details
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class TldwTimeoutError(TldwError):
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
message="The wait ended. Accepted work continues on the server.",
|
|
122
|
+
**details,
|
|
123
|
+
):
|
|
124
|
+
super().__init__(message, code="WAIT_TIMEOUT", **details)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class TldwCanceledError(TldwError):
|
|
128
|
+
def __init__(self):
|
|
129
|
+
super().__init__(
|
|
130
|
+
"The wait was canceled. Accepted work continues on the server.",
|
|
131
|
+
code="WAIT_CANCELED",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class TldwJobError(TldwError):
|
|
136
|
+
def __init__(self, resource, kind="job"):
|
|
137
|
+
error = resource.get("error") or {}
|
|
138
|
+
error = error if isinstance(error, dict) else {}
|
|
139
|
+
super().__init__(
|
|
140
|
+
error.get("message")
|
|
141
|
+
if isinstance(error.get("message"), str)
|
|
142
|
+
else f"The {kind} {resource['status']}.",
|
|
143
|
+
code=error.get("code")
|
|
144
|
+
if isinstance(error.get("code"), str)
|
|
145
|
+
else ("CANCELED" if resource["status"] == "canceled" else "JOB_FAILED"),
|
|
146
|
+
**{f"{kind}_id": resource["id"]},
|
|
147
|
+
)
|
|
148
|
+
self.resource = resource
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _identifier(value, label="ID"):
|
|
152
|
+
try:
|
|
153
|
+
if not isinstance(value, str) or len(value) != 36:
|
|
154
|
+
raise ValueError()
|
|
155
|
+
parsed = uuid.UUID(value)
|
|
156
|
+
if str(parsed) != value.lower():
|
|
157
|
+
raise ValueError()
|
|
158
|
+
return str(parsed)
|
|
159
|
+
except (ValueError, AttributeError):
|
|
160
|
+
raise ValueError(f"{label} must be a UUID.") from None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _bound(value, label, minimum, maximum):
|
|
164
|
+
if (
|
|
165
|
+
isinstance(value, bool)
|
|
166
|
+
or not isinstance(value, (int, float))
|
|
167
|
+
or not math.isfinite(value)
|
|
168
|
+
or not minimum <= value <= maximum
|
|
169
|
+
):
|
|
170
|
+
raise ValueError(f"{label} must be between {minimum} and {maximum}.")
|
|
171
|
+
return float(value)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _object(value, label):
|
|
175
|
+
if not isinstance(value, dict):
|
|
176
|
+
raise TldwError(f"Invalid {label} response.", code="INVALID_RESPONSE")
|
|
177
|
+
return value
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _idempotency_key(value):
|
|
181
|
+
value = str(uuid.uuid4()) if value is None else value
|
|
182
|
+
if (
|
|
183
|
+
not isinstance(value, str)
|
|
184
|
+
or not 1 <= len(value) <= 128
|
|
185
|
+
or not all(33 <= ord(char) <= 126 for char in value)
|
|
186
|
+
):
|
|
187
|
+
raise ValueError(
|
|
188
|
+
"idempotency_key must contain 1–128 printable ASCII characters without whitespace."
|
|
189
|
+
)
|
|
190
|
+
return value
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _retry_delay(value):
|
|
194
|
+
if not value:
|
|
195
|
+
return None
|
|
196
|
+
try:
|
|
197
|
+
seconds = float(value)
|
|
198
|
+
if not math.isfinite(seconds) or seconds < 0:
|
|
199
|
+
return None
|
|
200
|
+
return min(3600, seconds)
|
|
201
|
+
except ValueError:
|
|
202
|
+
try:
|
|
203
|
+
date = parsedate_to_datetime(value)
|
|
204
|
+
if date.tzinfo is None:
|
|
205
|
+
date = date.replace(tzinfo=timezone.utc)
|
|
206
|
+
return min(
|
|
207
|
+
3600, max(0, (date - datetime.now(timezone.utc)).total_seconds())
|
|
208
|
+
)
|
|
209
|
+
except (ValueError, TypeError, OverflowError):
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _safe_error_resource(resource, token):
|
|
214
|
+
if not token:
|
|
215
|
+
return resource
|
|
216
|
+
|
|
217
|
+
def redact(value, depth=0):
|
|
218
|
+
if depth > 100:
|
|
219
|
+
return "[OMITTED]"
|
|
220
|
+
if isinstance(value, str):
|
|
221
|
+
return value.replace(token, "[REDACTED]")
|
|
222
|
+
if isinstance(value, list):
|
|
223
|
+
return [redact(item, depth + 1) for item in value]
|
|
224
|
+
if isinstance(value, dict):
|
|
225
|
+
return {
|
|
226
|
+
str(field).replace(token, "[REDACTED]"): redact(item, depth + 1)
|
|
227
|
+
for field, item in value.items()
|
|
228
|
+
}
|
|
229
|
+
return value
|
|
230
|
+
|
|
231
|
+
return redact(resource)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class _NoRedirect(HTTPRedirectHandler):
|
|
235
|
+
def redirect_request(self, request, fp, code, message, headers, newurl):
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _remaining(deadline):
|
|
240
|
+
remaining = deadline - time.monotonic()
|
|
241
|
+
if remaining <= 0:
|
|
242
|
+
raise TimeoutError()
|
|
243
|
+
return remaining
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class _DeadlineReader(io.RawIOBase):
|
|
247
|
+
"""Refresh the socket budget before every read, including HTTP framing."""
|
|
248
|
+
|
|
249
|
+
def __init__(self, raw, connection, deadline):
|
|
250
|
+
self._raw = raw
|
|
251
|
+
self._connection = connection
|
|
252
|
+
self._deadline = deadline
|
|
253
|
+
|
|
254
|
+
def readable(self):
|
|
255
|
+
return True
|
|
256
|
+
|
|
257
|
+
def readinto(self, buffer):
|
|
258
|
+
self._connection.settimeout(_remaining(self._deadline))
|
|
259
|
+
count = self._raw.readinto(buffer)
|
|
260
|
+
_remaining(self._deadline)
|
|
261
|
+
return count
|
|
262
|
+
|
|
263
|
+
def close(self):
|
|
264
|
+
try:
|
|
265
|
+
self._raw.close()
|
|
266
|
+
finally:
|
|
267
|
+
super().close()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class _DeadlineSocket:
|
|
271
|
+
"""Adapt http.client's socket/file interface without background threads."""
|
|
272
|
+
|
|
273
|
+
def __init__(self, connection, deadline):
|
|
274
|
+
self._connection = connection
|
|
275
|
+
self._deadline = deadline
|
|
276
|
+
|
|
277
|
+
def __getattr__(self, name):
|
|
278
|
+
return getattr(self._connection, name)
|
|
279
|
+
|
|
280
|
+
def gettimeout(self):
|
|
281
|
+
# ssl.wrap_socket inherits this remaining budget for its handshake.
|
|
282
|
+
return _remaining(self._deadline)
|
|
283
|
+
|
|
284
|
+
def sendall(self, data):
|
|
285
|
+
self._connection.settimeout(_remaining(self._deadline))
|
|
286
|
+
self._connection.sendall(data)
|
|
287
|
+
_remaining(self._deadline)
|
|
288
|
+
|
|
289
|
+
def makefile(self, mode):
|
|
290
|
+
raw = self._connection.makefile(mode, buffering=0)
|
|
291
|
+
return io.BufferedReader(_DeadlineReader(raw, self._connection, self._deadline))
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class _DeadlineConnection:
|
|
295
|
+
def __init__(self, *args, deadline, **kwargs):
|
|
296
|
+
self._deadline = deadline
|
|
297
|
+
super().__init__(*args, **kwargs)
|
|
298
|
+
self._create_connection = self._connect_socket
|
|
299
|
+
|
|
300
|
+
def _connect_socket(self, address, timeout, source_address):
|
|
301
|
+
# The system resolver is synchronous and has no portable cancellation
|
|
302
|
+
# API. Check elapsed time immediately afterwards, before opening a socket.
|
|
303
|
+
addresses = socket.getaddrinfo(*address, type=socket.SOCK_STREAM)
|
|
304
|
+
_remaining(self._deadline)
|
|
305
|
+
last_error = None
|
|
306
|
+
for family, kind, protocol, _, destination in addresses:
|
|
307
|
+
connection = socket.socket(family, kind, protocol)
|
|
308
|
+
try:
|
|
309
|
+
connection.settimeout(_remaining(self._deadline))
|
|
310
|
+
if source_address:
|
|
311
|
+
connection.bind(source_address)
|
|
312
|
+
connection.connect(destination)
|
|
313
|
+
_remaining(self._deadline)
|
|
314
|
+
return _DeadlineSocket(connection, self._deadline)
|
|
315
|
+
except OSError as error:
|
|
316
|
+
connection.close()
|
|
317
|
+
last_error = error
|
|
318
|
+
if last_error is not None:
|
|
319
|
+
raise last_error
|
|
320
|
+
raise OSError("The API host did not resolve to a stream address.")
|
|
321
|
+
|
|
322
|
+
def connect(self):
|
|
323
|
+
super().connect()
|
|
324
|
+
# HTTPSConnection replaces the initial socket during TLS negotiation.
|
|
325
|
+
if not isinstance(self.sock, _DeadlineSocket):
|
|
326
|
+
self.sock = _DeadlineSocket(self.sock, self._deadline)
|
|
327
|
+
_remaining(self._deadline)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
class _DeadlineHTTPConnection(_DeadlineConnection, HTTPConnection):
|
|
331
|
+
pass
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class _DeadlineHTTPSConnection(_DeadlineConnection, HTTPSConnection):
|
|
335
|
+
pass
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
class _DeadlineHTTPHandler(HTTPHandler):
|
|
339
|
+
def __init__(self, deadline):
|
|
340
|
+
super().__init__()
|
|
341
|
+
self._deadline = deadline
|
|
342
|
+
|
|
343
|
+
def http_open(self, request):
|
|
344
|
+
return self.do_open(
|
|
345
|
+
lambda *args, **kwargs: _DeadlineHTTPConnection(
|
|
346
|
+
*args, deadline=self._deadline, **kwargs
|
|
347
|
+
),
|
|
348
|
+
request,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class _DeadlineHTTPSHandler(HTTPSHandler):
|
|
353
|
+
def __init__(self, deadline):
|
|
354
|
+
super().__init__()
|
|
355
|
+
self._deadline = deadline
|
|
356
|
+
|
|
357
|
+
def https_open(self, request):
|
|
358
|
+
return self.do_open(
|
|
359
|
+
lambda *args, **kwargs: _DeadlineHTTPSConnection(
|
|
360
|
+
*args, deadline=self._deadline, **kwargs
|
|
361
|
+
),
|
|
362
|
+
request,
|
|
363
|
+
context=self._context,
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _http_transport(method, url, headers, payload, timeout):
|
|
368
|
+
"""Return status/headers/body; never follow redirects or environment proxies."""
|
|
369
|
+
deadline = time.monotonic() + timeout
|
|
370
|
+
opener = build_opener(
|
|
371
|
+
_NoRedirect(),
|
|
372
|
+
ProxyHandler({}),
|
|
373
|
+
_DeadlineHTTPHandler(deadline),
|
|
374
|
+
_DeadlineHTTPSHandler(deadline),
|
|
375
|
+
)
|
|
376
|
+
request = Request(url, data=payload, headers=headers, method=method)
|
|
377
|
+
try:
|
|
378
|
+
response = opener.open(request, timeout=_remaining(deadline))
|
|
379
|
+
except HTTPError as error:
|
|
380
|
+
response = error
|
|
381
|
+
with response:
|
|
382
|
+
_remaining(deadline)
|
|
383
|
+
body = response.read(_MAX_BODY + 1)
|
|
384
|
+
_remaining(deadline)
|
|
385
|
+
return (
|
|
386
|
+
response.status,
|
|
387
|
+
{key.lower(): value for key, value in response.headers.items()},
|
|
388
|
+
body,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
class TldwClient:
|
|
393
|
+
"""Synchronous client; ``cancel_event`` cooperatively stops bounded polling.
|
|
394
|
+
|
|
395
|
+
An in-flight HTTP read can finish before cancellation is observed; it is
|
|
396
|
+
bounded by request_timeout_seconds. The SDK never sends a job cancellation.
|
|
397
|
+
"""
|
|
398
|
+
|
|
399
|
+
def __init__(
|
|
400
|
+
self,
|
|
401
|
+
*,
|
|
402
|
+
api_url,
|
|
403
|
+
token=None,
|
|
404
|
+
allow_anonymous=False,
|
|
405
|
+
request_timeout_seconds=30,
|
|
406
|
+
transport=None,
|
|
407
|
+
_clock=time.monotonic,
|
|
408
|
+
_sleep=time.sleep,
|
|
409
|
+
):
|
|
410
|
+
try:
|
|
411
|
+
parsed = urlsplit(api_url)
|
|
412
|
+
host = parsed.hostname
|
|
413
|
+
port = parsed.port
|
|
414
|
+
except (TypeError, ValueError):
|
|
415
|
+
raise ValueError("api_url must be an absolute HTTPS origin.") from None
|
|
416
|
+
loopback = host in {"localhost", "127.0.0.1", "::1"}
|
|
417
|
+
if (
|
|
418
|
+
not host
|
|
419
|
+
or (parsed.scheme != "https" and not (parsed.scheme == "http" and loopback))
|
|
420
|
+
or parsed.username is not None
|
|
421
|
+
or parsed.password is not None
|
|
422
|
+
or parsed.query
|
|
423
|
+
or parsed.fragment
|
|
424
|
+
or parsed.path not in {"", "/"}
|
|
425
|
+
):
|
|
426
|
+
raise ValueError(
|
|
427
|
+
"api_url must be an HTTPS origin without credentials, query, fragment or path; HTTP is allowed only on loopback."
|
|
428
|
+
)
|
|
429
|
+
if token is not None and (
|
|
430
|
+
not isinstance(token, str)
|
|
431
|
+
or not token
|
|
432
|
+
or not all(33 <= ord(char) <= 126 for char in token)
|
|
433
|
+
):
|
|
434
|
+
raise ValueError(
|
|
435
|
+
"token must be nonempty printable ASCII without whitespace."
|
|
436
|
+
)
|
|
437
|
+
if not token and not (allow_anonymous and loopback):
|
|
438
|
+
raise ValueError(
|
|
439
|
+
"A tldw API token is required. Anonymous access requires explicit loopback development mode."
|
|
440
|
+
)
|
|
441
|
+
# Normalize the origin while retaining IPv6 brackets and a configured port.
|
|
442
|
+
authority = f"[{host}]" if ":" in host else host
|
|
443
|
+
if port is not None:
|
|
444
|
+
authority += f":{port}"
|
|
445
|
+
self.api_url = f"{parsed.scheme}://{authority}"
|
|
446
|
+
self.__token = token
|
|
447
|
+
self.request_timeout_seconds = _bound(
|
|
448
|
+
request_timeout_seconds, "request_timeout_seconds", 0.001, 120
|
|
449
|
+
)
|
|
450
|
+
self._transport = transport or _http_transport
|
|
451
|
+
self._clock = _clock
|
|
452
|
+
self._sleep = _sleep
|
|
453
|
+
self._poll_hints = {}
|
|
454
|
+
|
|
455
|
+
def _check_canceled(self, cancel_event):
|
|
456
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
457
|
+
raise TldwCanceledError()
|
|
458
|
+
|
|
459
|
+
def _pause(self, seconds, cancel_event):
|
|
460
|
+
self._check_canceled(cancel_event)
|
|
461
|
+
if cancel_event is None:
|
|
462
|
+
self._sleep(seconds)
|
|
463
|
+
elif cancel_event.wait(seconds):
|
|
464
|
+
raise TldwCanceledError()
|
|
465
|
+
|
|
466
|
+
def _request(
|
|
467
|
+
self,
|
|
468
|
+
method,
|
|
469
|
+
path,
|
|
470
|
+
*,
|
|
471
|
+
body=None,
|
|
472
|
+
idempotency_key=None,
|
|
473
|
+
cancel_event=None,
|
|
474
|
+
deadline=None,
|
|
475
|
+
):
|
|
476
|
+
deadline = (
|
|
477
|
+
self._clock() + self.request_timeout_seconds
|
|
478
|
+
if deadline is None
|
|
479
|
+
else deadline
|
|
480
|
+
)
|
|
481
|
+
payload = (
|
|
482
|
+
json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode()
|
|
483
|
+
if body is not None
|
|
484
|
+
else None
|
|
485
|
+
)
|
|
486
|
+
headers = {"Accept": "application/json"}
|
|
487
|
+
if self.__token:
|
|
488
|
+
headers["Authorization"] = f"Bearer {self.__token}"
|
|
489
|
+
if payload is not None:
|
|
490
|
+
headers["Content-Type"] = "application/json"
|
|
491
|
+
if idempotency_key:
|
|
492
|
+
headers["Idempotency-Key"] = idempotency_key
|
|
493
|
+
for attempt in range(4):
|
|
494
|
+
self._check_canceled(cancel_event)
|
|
495
|
+
remaining = deadline - self._clock()
|
|
496
|
+
if remaining <= 0:
|
|
497
|
+
raise TldwTimeoutError()
|
|
498
|
+
try:
|
|
499
|
+
status, response_headers, raw = self._transport(
|
|
500
|
+
method,
|
|
501
|
+
self.api_url + path,
|
|
502
|
+
dict(headers),
|
|
503
|
+
payload,
|
|
504
|
+
min(remaining, self.request_timeout_seconds),
|
|
505
|
+
)
|
|
506
|
+
except TimeoutError:
|
|
507
|
+
if method != "GET" or self._clock() >= deadline:
|
|
508
|
+
raise TldwTimeoutError(
|
|
509
|
+
"The API request timed out. Accepted work continues on the server."
|
|
510
|
+
if method == "GET"
|
|
511
|
+
else "The API request timed out. Retry submission with the same idempotency key if its outcome is unknown."
|
|
512
|
+
) from None
|
|
513
|
+
# A slow read (for example one poll inside a long wait) has no side
|
|
514
|
+
# effects; retry it while the caller's deadline allows.
|
|
515
|
+
error = TldwTimeoutError(
|
|
516
|
+
"The API request timed out. Accepted work continues on the server."
|
|
517
|
+
)
|
|
518
|
+
if attempt == 3:
|
|
519
|
+
raise error from None
|
|
520
|
+
delay = min(10, 0.5 * 2**attempt)
|
|
521
|
+
except (URLError, OSError, HTTPException):
|
|
522
|
+
if self._clock() >= deadline:
|
|
523
|
+
raise TldwTimeoutError() from None
|
|
524
|
+
error = TldwError(
|
|
525
|
+
"Could not reach the configured tldw API. Check the connection before retrying.",
|
|
526
|
+
code="NETWORK_ERROR",
|
|
527
|
+
)
|
|
528
|
+
if attempt == 3:
|
|
529
|
+
raise error from None
|
|
530
|
+
delay = min(10, 0.5 * 2**attempt)
|
|
531
|
+
else:
|
|
532
|
+
self._check_canceled(cancel_event)
|
|
533
|
+
if self._clock() >= deadline:
|
|
534
|
+
raise TldwTimeoutError()
|
|
535
|
+
if len(raw) > _MAX_BODY:
|
|
536
|
+
raise TldwError(
|
|
537
|
+
"API response exceeded the client size limit.",
|
|
538
|
+
code="INVALID_RESPONSE",
|
|
539
|
+
)
|
|
540
|
+
retry_after = _retry_delay(response_headers.get("retry-after"))
|
|
541
|
+
try:
|
|
542
|
+
data = json.loads(raw)
|
|
543
|
+
except (ValueError, UnicodeError):
|
|
544
|
+
if status not in _TRANSIENT:
|
|
545
|
+
raise TldwError(
|
|
546
|
+
"API returned invalid JSON.",
|
|
547
|
+
code="INVALID_RESPONSE",
|
|
548
|
+
status=status,
|
|
549
|
+
) from None
|
|
550
|
+
data = {}
|
|
551
|
+
if 200 <= status < 300:
|
|
552
|
+
return _object(data, "JSON"), retry_after
|
|
553
|
+
detail = data.get("error") if isinstance(data, dict) else {}
|
|
554
|
+
detail = detail if isinstance(detail, dict) else {}
|
|
555
|
+
|
|
556
|
+
def safe(value, fallback=None):
|
|
557
|
+
value = value if isinstance(value, str) else fallback
|
|
558
|
+
return (
|
|
559
|
+
value.replace(self.__token, "[REDACTED]")
|
|
560
|
+
if value and self.__token
|
|
561
|
+
else value
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
extra = detail.get("details")
|
|
565
|
+
extra = (
|
|
566
|
+
_safe_error_resource(extra, self.__token)
|
|
567
|
+
if isinstance(extra, dict)
|
|
568
|
+
else None
|
|
569
|
+
)
|
|
570
|
+
try:
|
|
571
|
+
active_job = _identifier((extra or {}).get("job_id"))
|
|
572
|
+
except ValueError:
|
|
573
|
+
active_job = None
|
|
574
|
+
error = TldwError(
|
|
575
|
+
safe(detail.get("message"), f"API request failed ({status})."),
|
|
576
|
+
code=safe(detail.get("code"), "HTTP_ERROR"),
|
|
577
|
+
status=status,
|
|
578
|
+
request_id=safe(
|
|
579
|
+
detail.get("request_id") or response_headers.get("x-request-id")
|
|
580
|
+
),
|
|
581
|
+
retry_after_seconds=retry_after,
|
|
582
|
+
job_id=active_job,
|
|
583
|
+
details=extra,
|
|
584
|
+
)
|
|
585
|
+
if status not in _TRANSIENT or attempt == 3:
|
|
586
|
+
raise error
|
|
587
|
+
delay = max(retry_after or 0, min(10, 0.5 * 2**attempt))
|
|
588
|
+
if self._clock() + delay >= deadline:
|
|
589
|
+
if error.status:
|
|
590
|
+
raise error
|
|
591
|
+
raise TldwTimeoutError()
|
|
592
|
+
self._pause(delay, cancel_event)
|
|
593
|
+
raise TldwError("API retry limit reached.", code="RETRY_LIMIT")
|
|
594
|
+
|
|
595
|
+
def submit_analysis(
|
|
596
|
+
self,
|
|
597
|
+
media,
|
|
598
|
+
*,
|
|
599
|
+
instructions=None,
|
|
600
|
+
idempotency_key=None,
|
|
601
|
+
cancel_event=None,
|
|
602
|
+
transcription=None,
|
|
603
|
+
max_credits=None,
|
|
604
|
+
force_asr=None,
|
|
605
|
+
refresh_source=None,
|
|
606
|
+
):
|
|
607
|
+
"""Submit text or a public URL and retain the returned job_id before waiting.
|
|
608
|
+
|
|
609
|
+
X URLs default to auto: accessible captions for one attached video are
|
|
610
|
+
included when the deployment supports it. Explicit scope=post reads
|
|
611
|
+
only post text. Use instructions for focus or a requested output language
|
|
612
|
+
(for example, 'Summarize the spoken content in Spanish.'). X audio
|
|
613
|
+
transcription and visual analysis are not enabled. Missing captions fail
|
|
614
|
+
explicitly. Reuse the exact media, instructions and idempotency_key
|
|
615
|
+
after an unknown submission outcome.
|
|
616
|
+
X/article sources may reuse a saved snapshot for 24 hours. Set
|
|
617
|
+
refresh_source=True to fetch current source content without forcing ASR.
|
|
618
|
+
"""
|
|
619
|
+
return self._submit_media(
|
|
620
|
+
"/v1/analyses",
|
|
621
|
+
media,
|
|
622
|
+
instructions=instructions,
|
|
623
|
+
idempotency_key=idempotency_key,
|
|
624
|
+
cancel_event=cancel_event,
|
|
625
|
+
transcription=transcription,
|
|
626
|
+
max_credits=max_credits,
|
|
627
|
+
force_asr=force_asr,
|
|
628
|
+
refresh_source=refresh_source,
|
|
629
|
+
)
|
|
630
|
+
|
|
631
|
+
def submit_transcript(
|
|
632
|
+
self,
|
|
633
|
+
media=None,
|
|
634
|
+
*,
|
|
635
|
+
upload_id=None,
|
|
636
|
+
preferred_caption_languages=None,
|
|
637
|
+
idempotency_key=None,
|
|
638
|
+
cancel_event=None,
|
|
639
|
+
transcription=None,
|
|
640
|
+
max_credits=None,
|
|
641
|
+
force_asr=None,
|
|
642
|
+
refresh_source=None,
|
|
643
|
+
):
|
|
644
|
+
"""Save original captions/source text without summary generation or an LLM call."""
|
|
645
|
+
return self._submit_media(
|
|
646
|
+
"/v1/transcripts",
|
|
647
|
+
media,
|
|
648
|
+
idempotency_key=idempotency_key,
|
|
649
|
+
cancel_event=cancel_event,
|
|
650
|
+
transcription=transcription,
|
|
651
|
+
max_credits=max_credits,
|
|
652
|
+
force_asr=force_asr,
|
|
653
|
+
refresh_source=refresh_source,
|
|
654
|
+
upload_id=upload_id,
|
|
655
|
+
preferred_caption_languages=preferred_caption_languages,
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
def _submit_media(
|
|
659
|
+
self,
|
|
660
|
+
path,
|
|
661
|
+
media,
|
|
662
|
+
*,
|
|
663
|
+
instructions=None,
|
|
664
|
+
idempotency_key=None,
|
|
665
|
+
cancel_event=None,
|
|
666
|
+
transcription=None,
|
|
667
|
+
max_credits=None,
|
|
668
|
+
force_asr=None,
|
|
669
|
+
refresh_source=None,
|
|
670
|
+
upload_id=None,
|
|
671
|
+
preferred_caption_languages=None,
|
|
672
|
+
):
|
|
673
|
+
if refresh_source is not None and not isinstance(refresh_source, bool):
|
|
674
|
+
raise ValueError("refresh_source must be a boolean.")
|
|
675
|
+
stable_key = _idempotency_key(idempotency_key)
|
|
676
|
+
body = {
|
|
677
|
+
**_spending_options(transcription, max_credits, force_asr),
|
|
678
|
+
}
|
|
679
|
+
if path == "/v1/transcripts":
|
|
680
|
+
if (media is not None) == (upload_id is not None):
|
|
681
|
+
raise ValueError("Provide exactly one media or upload_id.")
|
|
682
|
+
if upload_id is not None:
|
|
683
|
+
body["upload_id"] = _identifier(upload_id, "Upload ID")
|
|
684
|
+
if preferred_caption_languages is not None:
|
|
685
|
+
if (
|
|
686
|
+
not isinstance(preferred_caption_languages, list)
|
|
687
|
+
or len(preferred_caption_languages) > 10
|
|
688
|
+
or any(
|
|
689
|
+
not isinstance(value, str)
|
|
690
|
+
or not re.fullmatch(
|
|
691
|
+
r"[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*", value
|
|
692
|
+
)
|
|
693
|
+
for value in preferred_caption_languages
|
|
694
|
+
)
|
|
695
|
+
):
|
|
696
|
+
raise ValueError(
|
|
697
|
+
"preferred_caption_languages must contain at most 10 BCP-47 language tags."
|
|
698
|
+
)
|
|
699
|
+
body["preferred_caption_languages"] = preferred_caption_languages
|
|
700
|
+
if media is not None:
|
|
701
|
+
body["media"] = media
|
|
702
|
+
if instructions is not None:
|
|
703
|
+
body["instructions"] = instructions
|
|
704
|
+
if refresh_source is not None:
|
|
705
|
+
body["refresh_source"] = refresh_source
|
|
706
|
+
try:
|
|
707
|
+
data, retry_after = self._request(
|
|
708
|
+
"POST",
|
|
709
|
+
path,
|
|
710
|
+
body=body,
|
|
711
|
+
idempotency_key=stable_key,
|
|
712
|
+
cancel_event=cancel_event,
|
|
713
|
+
)
|
|
714
|
+
except TldwError as error:
|
|
715
|
+
error.idempotency_key = stable_key
|
|
716
|
+
raise
|
|
717
|
+
try:
|
|
718
|
+
_identifier(data.get("job_id"), "Accepted job ID")
|
|
719
|
+
if (
|
|
720
|
+
not isinstance(data.get("job"), dict)
|
|
721
|
+
or data["job"].get("id") != data["job_id"]
|
|
722
|
+
):
|
|
723
|
+
raise ValueError()
|
|
724
|
+
if path == "/v1/transcripts" and (
|
|
725
|
+
data.get("output_mode") != "transcript"
|
|
726
|
+
or data["job"].get("output_mode") != "transcript"
|
|
727
|
+
):
|
|
728
|
+
raise ValueError()
|
|
729
|
+
except ValueError:
|
|
730
|
+
error = TldwError(
|
|
731
|
+
"API returned an invalid accepted job.", code="INVALID_RESPONSE"
|
|
732
|
+
)
|
|
733
|
+
error.idempotency_key = stable_key
|
|
734
|
+
raise error from None
|
|
735
|
+
self._remember_poll_hint(
|
|
736
|
+
data["job_id"], "job", data.get("next_poll_after_ms"), retry_after
|
|
737
|
+
)
|
|
738
|
+
return data
|
|
739
|
+
|
|
740
|
+
def _remember_poll_hint(self, resource_id, kind, suggested, retry_after):
|
|
741
|
+
suggested = (
|
|
742
|
+
min(3600, suggested / 1000)
|
|
743
|
+
if isinstance(suggested, (int, float))
|
|
744
|
+
and math.isfinite(suggested)
|
|
745
|
+
and suggested > 0
|
|
746
|
+
else 0
|
|
747
|
+
)
|
|
748
|
+
delay = max(suggested, retry_after or 0)
|
|
749
|
+
if delay:
|
|
750
|
+
if len(self._poll_hints) >= 256:
|
|
751
|
+
self._poll_hints.pop(next(iter(self._poll_hints)))
|
|
752
|
+
self._poll_hints[(kind, resource_id)] = (self._clock() + delay, delay)
|
|
753
|
+
|
|
754
|
+
def _job(self, data, resource_id):
|
|
755
|
+
_object(data, "job")
|
|
756
|
+
progress = data.get("progress")
|
|
757
|
+
if (
|
|
758
|
+
data.get("id") != resource_id
|
|
759
|
+
or data.get("status") not in _JOB_STATES
|
|
760
|
+
or not isinstance(progress, int)
|
|
761
|
+
or isinstance(progress, bool)
|
|
762
|
+
or not 0 <= progress <= 100
|
|
763
|
+
or (
|
|
764
|
+
data.get("output_mode") is not None
|
|
765
|
+
and (
|
|
766
|
+
not isinstance(data["output_mode"], str)
|
|
767
|
+
or data["output_mode"] not in {"summary", "analysis", "transcript"}
|
|
768
|
+
)
|
|
769
|
+
)
|
|
770
|
+
):
|
|
771
|
+
raise TldwError(
|
|
772
|
+
"API returned an invalid job.",
|
|
773
|
+
code="INVALID_RESPONSE",
|
|
774
|
+
job_id=resource_id,
|
|
775
|
+
)
|
|
776
|
+
if data["status"] == "completed":
|
|
777
|
+
try:
|
|
778
|
+
_identifier(
|
|
779
|
+
data.get(
|
|
780
|
+
"transcript_id"
|
|
781
|
+
if data.get("output_mode") == "transcript"
|
|
782
|
+
else "summary_id"
|
|
783
|
+
)
|
|
784
|
+
)
|
|
785
|
+
except ValueError:
|
|
786
|
+
raise TldwError(
|
|
787
|
+
"Completed job has no valid result ID.",
|
|
788
|
+
code="INVALID_RESPONSE",
|
|
789
|
+
job_id=resource_id,
|
|
790
|
+
) from None
|
|
791
|
+
return data
|
|
792
|
+
|
|
793
|
+
def get_job(self, job_id, *, cancel_event=None):
|
|
794
|
+
job_id = _identifier(job_id)
|
|
795
|
+
data, _ = self._request("GET", f"/v1/jobs/{job_id}", cancel_event=cancel_event)
|
|
796
|
+
return self._job(data, job_id)
|
|
797
|
+
|
|
798
|
+
def get_summary(self, summary_id, *, cancel_event=None):
|
|
799
|
+
summary_id = _identifier(summary_id)
|
|
800
|
+
data, _ = self._request(
|
|
801
|
+
"GET", f"/v1/summaries/{summary_id}", cancel_event=cancel_event
|
|
802
|
+
)
|
|
803
|
+
if data.get("id") != summary_id or not isinstance(data.get("content"), dict):
|
|
804
|
+
raise TldwError("API returned an invalid summary.", code="INVALID_RESPONSE")
|
|
805
|
+
return data
|
|
806
|
+
|
|
807
|
+
def get_usage(self, *, cancel_event=None):
|
|
808
|
+
return self._request("GET", "/v1/me", cancel_event=cancel_event)[0]
|
|
809
|
+
|
|
810
|
+
def get_transcript(self, summary_id, *, cancel_event=None):
|
|
811
|
+
"""Read the owner-private saved source separately from generated output.
|
|
812
|
+
|
|
813
|
+
Timed video sources return segments; text/X sources return blocks and
|
|
814
|
+
text. X caption blocks retain start_s/end_s. Requires summaries:read.
|
|
815
|
+
"""
|
|
816
|
+
summary_id = _identifier(summary_id)
|
|
817
|
+
data, _ = self._request(
|
|
818
|
+
"GET", f"/v1/summaries/{summary_id}/transcript", cancel_event=cancel_event
|
|
819
|
+
)
|
|
820
|
+
return self._transcript(data)
|
|
821
|
+
|
|
822
|
+
def _transcript(self, data):
|
|
823
|
+
try:
|
|
824
|
+
_identifier(data.get("transcript_id"))
|
|
825
|
+
except (ValueError, TypeError):
|
|
826
|
+
raise TldwError(
|
|
827
|
+
"API returned an invalid transcript.", code="INVALID_RESPONSE"
|
|
828
|
+
) from None
|
|
829
|
+
if (
|
|
830
|
+
not isinstance(data.get("timed"), bool)
|
|
831
|
+
or "text" not in data
|
|
832
|
+
or "language" not in data
|
|
833
|
+
or not isinstance(data.get("source"), str)
|
|
834
|
+
or (
|
|
835
|
+
data.get("language") is not None
|
|
836
|
+
and not isinstance(data["language"], str)
|
|
837
|
+
)
|
|
838
|
+
or not isinstance(data.get("segments"), list)
|
|
839
|
+
or not isinstance(data.get("blocks"), list)
|
|
840
|
+
or (
|
|
841
|
+
data.get("text") is not None
|
|
842
|
+
if data["timed"]
|
|
843
|
+
else not isinstance(data.get("text"), str)
|
|
844
|
+
)
|
|
845
|
+
):
|
|
846
|
+
raise TldwError(
|
|
847
|
+
"API returned an invalid transcript.", code="INVALID_RESPONSE"
|
|
848
|
+
)
|
|
849
|
+
return data
|
|
850
|
+
|
|
851
|
+
def list_transcripts(
|
|
852
|
+
self,
|
|
853
|
+
*,
|
|
854
|
+
limit=50,
|
|
855
|
+
cursor=None,
|
|
856
|
+
since=None,
|
|
857
|
+
until=None,
|
|
858
|
+
unread_only=False,
|
|
859
|
+
cancel_event=None,
|
|
860
|
+
):
|
|
861
|
+
"""List saved-source metadata by latest owned request; never marks read.
|
|
862
|
+
|
|
863
|
+
since is inclusive, until exclusive; follow next_cursor with the same filters.
|
|
864
|
+
Timestamp strings use ISO 8601. Requires summaries:read.
|
|
865
|
+
"""
|
|
866
|
+
if (
|
|
867
|
+
not isinstance(limit, int)
|
|
868
|
+
or isinstance(limit, bool)
|
|
869
|
+
or not 1 <= limit <= 100
|
|
870
|
+
):
|
|
871
|
+
raise ValueError("limit must be an integer between 1 and 100.")
|
|
872
|
+
if not isinstance(unread_only, bool):
|
|
873
|
+
raise TypeError("unread_only must be a boolean.")
|
|
874
|
+
query = {"limit": str(limit), "unread_only": str(unread_only).lower()}
|
|
875
|
+
for name, value in (("cursor", cursor), ("since", since), ("until", until)):
|
|
876
|
+
if value is None:
|
|
877
|
+
continue
|
|
878
|
+
if (
|
|
879
|
+
not isinstance(value, str)
|
|
880
|
+
or not value
|
|
881
|
+
or len(value) > (1024 if name == "cursor" else 128)
|
|
882
|
+
):
|
|
883
|
+
raise ValueError(f"{name} must be a bounded non-empty string.")
|
|
884
|
+
query[name] = value
|
|
885
|
+
data, _ = self._request(
|
|
886
|
+
"GET", "/v1/transcripts?" + urlencode(query), cancel_event=cancel_event
|
|
887
|
+
)
|
|
888
|
+
if not isinstance(data.get("items"), list) or (
|
|
889
|
+
data.get("next_cursor") is not None
|
|
890
|
+
and not isinstance(data["next_cursor"], str)
|
|
891
|
+
):
|
|
892
|
+
raise TldwError(
|
|
893
|
+
"API returned an invalid transcript list.", code="INVALID_RESPONSE"
|
|
894
|
+
)
|
|
895
|
+
return data
|
|
896
|
+
|
|
897
|
+
def get_saved_transcript(self, transcript_id, *, cancel_event=None):
|
|
898
|
+
"""Read the full original source by transcript id; never marks read."""
|
|
899
|
+
transcript_id = _identifier(transcript_id)
|
|
900
|
+
data, _ = self._request(
|
|
901
|
+
"GET", f"/v1/transcripts/{transcript_id}", cancel_event=cancel_event
|
|
902
|
+
)
|
|
903
|
+
return self._transcript(data)
|
|
904
|
+
|
|
905
|
+
def get_saved_transcript_metadata(self, transcript_id, *, cancel_event=None):
|
|
906
|
+
"""Read source metadata by id without loading text, processing or marking read."""
|
|
907
|
+
transcript_id = _identifier(transcript_id)
|
|
908
|
+
data, _ = self._request(
|
|
909
|
+
"GET",
|
|
910
|
+
f"/v1/transcripts/{transcript_id}/metadata",
|
|
911
|
+
cancel_event=cancel_event,
|
|
912
|
+
)
|
|
913
|
+
if (
|
|
914
|
+
data.get("id") != transcript_id
|
|
915
|
+
or not isinstance(data.get("unread"), bool)
|
|
916
|
+
or not isinstance(data.get("video"), dict)
|
|
917
|
+
or not isinstance(data["video"].get("id"), str)
|
|
918
|
+
):
|
|
919
|
+
raise TldwError(
|
|
920
|
+
"API returned invalid transcript metadata.", code="INVALID_RESPONSE"
|
|
921
|
+
)
|
|
922
|
+
return data
|
|
923
|
+
|
|
924
|
+
def get_saved_transcript_page(
|
|
925
|
+
self, transcript_id, *, offset=0, limit=100, cancel_event=None
|
|
926
|
+
):
|
|
927
|
+
"""Read a bounded source page; follow next_offset. Never marks read."""
|
|
928
|
+
transcript_id = _identifier(transcript_id)
|
|
929
|
+
for name, value, minimum, maximum in (
|
|
930
|
+
("offset", offset, 0, 1_000_000_000),
|
|
931
|
+
("limit", limit, 1, 500),
|
|
932
|
+
):
|
|
933
|
+
if (
|
|
934
|
+
not isinstance(value, int)
|
|
935
|
+
or isinstance(value, bool)
|
|
936
|
+
or not minimum <= value <= maximum
|
|
937
|
+
):
|
|
938
|
+
raise ValueError(
|
|
939
|
+
f"{name} must be an integer between {minimum} and {maximum}."
|
|
940
|
+
)
|
|
941
|
+
data, _ = self._request(
|
|
942
|
+
"GET",
|
|
943
|
+
f"/v1/transcripts/{transcript_id}/page?offset={offset}&limit={limit}",
|
|
944
|
+
cancel_event=cancel_event,
|
|
945
|
+
)
|
|
946
|
+
return self._transcript(data)
|
|
947
|
+
|
|
948
|
+
def mark_transcript_read(self, transcript_id, read=True, *, cancel_event=None):
|
|
949
|
+
"""Explicitly mark your source read/unread; repeated marks are idempotent."""
|
|
950
|
+
transcript_id = _identifier(transcript_id)
|
|
951
|
+
if not isinstance(read, bool):
|
|
952
|
+
raise TypeError("read must be a boolean.")
|
|
953
|
+
data, _ = self._request(
|
|
954
|
+
"PATCH",
|
|
955
|
+
f"/v1/transcripts/{transcript_id}/read",
|
|
956
|
+
body={"read": read},
|
|
957
|
+
cancel_event=cancel_event,
|
|
958
|
+
)
|
|
959
|
+
if (
|
|
960
|
+
data.get("id") != transcript_id
|
|
961
|
+
or not isinstance(data.get("unread"), bool)
|
|
962
|
+
or (
|
|
963
|
+
data.get("read_at") is not None and not isinstance(data["read_at"], str)
|
|
964
|
+
)
|
|
965
|
+
):
|
|
966
|
+
raise TldwError(
|
|
967
|
+
"API returned an invalid transcript read state.",
|
|
968
|
+
code="INVALID_RESPONSE",
|
|
969
|
+
)
|
|
970
|
+
return data
|
|
971
|
+
|
|
972
|
+
def _comparison(self, data, resource_id):
|
|
973
|
+
resource = _object(data.get("comparison"), "comparison")
|
|
974
|
+
if (
|
|
975
|
+
resource.get("id") != resource_id
|
|
976
|
+
or resource.get("status") not in _COMPARISON_STATES
|
|
977
|
+
):
|
|
978
|
+
raise TldwError(
|
|
979
|
+
"API returned an invalid comparison.",
|
|
980
|
+
code="INVALID_RESPONSE",
|
|
981
|
+
comparison_id=resource_id,
|
|
982
|
+
)
|
|
983
|
+
return resource
|
|
984
|
+
|
|
985
|
+
def _wait(
|
|
986
|
+
self,
|
|
987
|
+
resource_id,
|
|
988
|
+
kind,
|
|
989
|
+
*,
|
|
990
|
+
timeout_seconds=180,
|
|
991
|
+
poll_interval_seconds=1.5,
|
|
992
|
+
cancel_event=None,
|
|
993
|
+
on_progress=None,
|
|
994
|
+
):
|
|
995
|
+
deadline = self._clock() + _bound(
|
|
996
|
+
timeout_seconds, "timeout_seconds", 0.001, 3600
|
|
997
|
+
)
|
|
998
|
+
interval = _bound(poll_interval_seconds, "poll_interval_seconds", 0.25, 30)
|
|
999
|
+
if on_progress is not None and not callable(on_progress):
|
|
1000
|
+
raise ValueError("on_progress must be callable.")
|
|
1001
|
+
try:
|
|
1002
|
+
remembered = self._poll_hints.get((kind, resource_id))
|
|
1003
|
+
if remembered:
|
|
1004
|
+
interval = max(interval, remembered[1])
|
|
1005
|
+
delay = max(0, remembered[0] - self._clock())
|
|
1006
|
+
if self._clock() + delay >= deadline:
|
|
1007
|
+
raise TldwTimeoutError()
|
|
1008
|
+
if delay:
|
|
1009
|
+
self._pause(delay, cancel_event)
|
|
1010
|
+
while True:
|
|
1011
|
+
if self._clock() >= deadline:
|
|
1012
|
+
raise TldwTimeoutError()
|
|
1013
|
+
path = f"/v1/{'jobs' if kind == 'job' else 'comparisons'}/{resource_id}"
|
|
1014
|
+
data, retry_after = self._request(
|
|
1015
|
+
"GET", path, cancel_event=cancel_event, deadline=deadline
|
|
1016
|
+
)
|
|
1017
|
+
resource = (
|
|
1018
|
+
self._job(data, resource_id)
|
|
1019
|
+
if kind == "job"
|
|
1020
|
+
else self._comparison(data, resource_id)
|
|
1021
|
+
)
|
|
1022
|
+
if on_progress:
|
|
1023
|
+
on_progress(resource)
|
|
1024
|
+
if resource["status"] == "completed":
|
|
1025
|
+
self._poll_hints.pop((kind, resource_id), None)
|
|
1026
|
+
if kind == "comparison" and not resource.get("result"):
|
|
1027
|
+
raise TldwError(
|
|
1028
|
+
"Comparison result access is unavailable; summaries:read is required.",
|
|
1029
|
+
code="RESULT_SCOPE_REQUIRED",
|
|
1030
|
+
comparison_id=resource_id,
|
|
1031
|
+
)
|
|
1032
|
+
if kind == "comparison":
|
|
1033
|
+
_object(resource["result"], "comparison result")
|
|
1034
|
+
return resource
|
|
1035
|
+
if resource["status"] in {"failed", "canceled"}:
|
|
1036
|
+
self._poll_hints.pop((kind, resource_id), None)
|
|
1037
|
+
raise TldwJobError(
|
|
1038
|
+
_safe_error_resource(resource, self.__token), kind
|
|
1039
|
+
)
|
|
1040
|
+
suggested = resource.get("next_poll_after_ms")
|
|
1041
|
+
suggested = (
|
|
1042
|
+
min(3600, suggested / 1000)
|
|
1043
|
+
if isinstance(suggested, (int, float))
|
|
1044
|
+
and math.isfinite(suggested)
|
|
1045
|
+
and suggested > 0
|
|
1046
|
+
else 0
|
|
1047
|
+
)
|
|
1048
|
+
delay = max(interval, retry_after or 0, suggested)
|
|
1049
|
+
if self._clock() + delay >= deadline:
|
|
1050
|
+
raise TldwTimeoutError()
|
|
1051
|
+
self._pause(delay, cancel_event)
|
|
1052
|
+
except TldwError as error:
|
|
1053
|
+
setattr(error, f"{kind}_id", resource_id)
|
|
1054
|
+
raise
|
|
1055
|
+
|
|
1056
|
+
def wait_for_job(self, job_id, **options):
|
|
1057
|
+
return self._wait(_identifier(job_id), "job", **options)
|
|
1058
|
+
|
|
1059
|
+
def analyze(
|
|
1060
|
+
self,
|
|
1061
|
+
media,
|
|
1062
|
+
*,
|
|
1063
|
+
instructions=None,
|
|
1064
|
+
idempotency_key=None,
|
|
1065
|
+
cancel_event=None,
|
|
1066
|
+
timeout_seconds=180,
|
|
1067
|
+
on_progress=None,
|
|
1068
|
+
poll_interval_seconds=1.5,
|
|
1069
|
+
transcription=None,
|
|
1070
|
+
max_credits=None,
|
|
1071
|
+
force_asr=None,
|
|
1072
|
+
refresh_source=None,
|
|
1073
|
+
):
|
|
1074
|
+
_bound(timeout_seconds, "timeout_seconds", 0.001, 3600)
|
|
1075
|
+
_bound(poll_interval_seconds, "poll_interval_seconds", 0.25, 30)
|
|
1076
|
+
if on_progress is not None and not callable(on_progress):
|
|
1077
|
+
raise ValueError("on_progress must be callable.")
|
|
1078
|
+
receipt = self.submit_analysis(
|
|
1079
|
+
media,
|
|
1080
|
+
instructions=instructions,
|
|
1081
|
+
idempotency_key=idempotency_key,
|
|
1082
|
+
cancel_event=cancel_event,
|
|
1083
|
+
transcription=transcription,
|
|
1084
|
+
max_credits=max_credits,
|
|
1085
|
+
force_asr=force_asr,
|
|
1086
|
+
refresh_source=refresh_source,
|
|
1087
|
+
)
|
|
1088
|
+
try:
|
|
1089
|
+
job = self.wait_for_job(
|
|
1090
|
+
receipt["job_id"],
|
|
1091
|
+
timeout_seconds=timeout_seconds,
|
|
1092
|
+
cancel_event=cancel_event,
|
|
1093
|
+
on_progress=on_progress,
|
|
1094
|
+
poll_interval_seconds=poll_interval_seconds,
|
|
1095
|
+
)
|
|
1096
|
+
if job.get("output_mode") == "transcript":
|
|
1097
|
+
raise TldwError(
|
|
1098
|
+
"Expected an analysis result, received a transcript job.",
|
|
1099
|
+
code="INVALID_RESPONSE",
|
|
1100
|
+
)
|
|
1101
|
+
summary = self.get_summary(job["summary_id"], cancel_event=cancel_event)
|
|
1102
|
+
return {"receipt": receipt, "job": job, "summary": summary}
|
|
1103
|
+
except TldwError as error:
|
|
1104
|
+
error.job_id = receipt["job_id"]
|
|
1105
|
+
error.receipt = _safe_error_resource(receipt, self.__token)
|
|
1106
|
+
raise
|
|
1107
|
+
|
|
1108
|
+
def extract_transcript(
|
|
1109
|
+
self,
|
|
1110
|
+
media=None,
|
|
1111
|
+
*,
|
|
1112
|
+
upload_id=None,
|
|
1113
|
+
preferred_caption_languages=None,
|
|
1114
|
+
idempotency_key=None,
|
|
1115
|
+
cancel_event=None,
|
|
1116
|
+
timeout_seconds=180,
|
|
1117
|
+
on_progress=None,
|
|
1118
|
+
poll_interval_seconds=1.5,
|
|
1119
|
+
transcription=None,
|
|
1120
|
+
max_credits=None,
|
|
1121
|
+
force_asr=None,
|
|
1122
|
+
refresh_source=None,
|
|
1123
|
+
):
|
|
1124
|
+
"""Submit once, wait, then read original source; never requests generated analysis."""
|
|
1125
|
+
_bound(timeout_seconds, "timeout_seconds", 0.001, 3600)
|
|
1126
|
+
_bound(poll_interval_seconds, "poll_interval_seconds", 0.25, 30)
|
|
1127
|
+
if on_progress is not None and not callable(on_progress):
|
|
1128
|
+
raise ValueError("on_progress must be callable.")
|
|
1129
|
+
receipt = self.submit_transcript(
|
|
1130
|
+
media,
|
|
1131
|
+
idempotency_key=idempotency_key,
|
|
1132
|
+
cancel_event=cancel_event,
|
|
1133
|
+
transcription=transcription,
|
|
1134
|
+
max_credits=max_credits,
|
|
1135
|
+
force_asr=force_asr,
|
|
1136
|
+
refresh_source=refresh_source,
|
|
1137
|
+
upload_id=upload_id,
|
|
1138
|
+
preferred_caption_languages=preferred_caption_languages,
|
|
1139
|
+
)
|
|
1140
|
+
try:
|
|
1141
|
+
job = self.wait_for_job(
|
|
1142
|
+
receipt["job_id"],
|
|
1143
|
+
timeout_seconds=timeout_seconds,
|
|
1144
|
+
cancel_event=cancel_event,
|
|
1145
|
+
on_progress=on_progress,
|
|
1146
|
+
poll_interval_seconds=poll_interval_seconds,
|
|
1147
|
+
)
|
|
1148
|
+
if job.get("output_mode") != "transcript":
|
|
1149
|
+
raise TldwError(
|
|
1150
|
+
"Expected a transcript extraction result.", code="INVALID_RESPONSE"
|
|
1151
|
+
)
|
|
1152
|
+
transcript = self.get_saved_transcript(
|
|
1153
|
+
job["transcript_id"], cancel_event=cancel_event
|
|
1154
|
+
)
|
|
1155
|
+
return {"receipt": receipt, "job": job, "transcript": transcript}
|
|
1156
|
+
except TldwError as error:
|
|
1157
|
+
error.job_id = receipt["job_id"]
|
|
1158
|
+
error.receipt = _safe_error_resource(receipt, self.__token)
|
|
1159
|
+
raise
|
|
1160
|
+
|
|
1161
|
+
def compare_tiktoks(
|
|
1162
|
+
self,
|
|
1163
|
+
urls,
|
|
1164
|
+
*,
|
|
1165
|
+
instructions=None,
|
|
1166
|
+
idempotency_key=None,
|
|
1167
|
+
cancel_event=None,
|
|
1168
|
+
transcription=None,
|
|
1169
|
+
max_credits=None,
|
|
1170
|
+
force_asr=None,
|
|
1171
|
+
):
|
|
1172
|
+
if not isinstance(urls, list) or not 2 <= len(urls) <= 20:
|
|
1173
|
+
raise ValueError("Supply between 2 and 20 TikTok URLs.")
|
|
1174
|
+
stable_key = _idempotency_key(idempotency_key)
|
|
1175
|
+
body = {
|
|
1176
|
+
"urls": urls,
|
|
1177
|
+
**_spending_options(transcription, max_credits, force_asr),
|
|
1178
|
+
}
|
|
1179
|
+
if instructions is not None:
|
|
1180
|
+
body["instructions"] = instructions
|
|
1181
|
+
try:
|
|
1182
|
+
data, retry_after = self._request(
|
|
1183
|
+
"POST",
|
|
1184
|
+
"/v1/comparisons",
|
|
1185
|
+
body=body,
|
|
1186
|
+
idempotency_key=stable_key,
|
|
1187
|
+
cancel_event=cancel_event,
|
|
1188
|
+
)
|
|
1189
|
+
except TldwError as error:
|
|
1190
|
+
error.idempotency_key = stable_key
|
|
1191
|
+
raise
|
|
1192
|
+
try:
|
|
1193
|
+
resource_id = _identifier(
|
|
1194
|
+
_object(data.get("comparison"), "comparison").get("id"), "Comparison ID"
|
|
1195
|
+
)
|
|
1196
|
+
self._remember_poll_hint(
|
|
1197
|
+
resource_id,
|
|
1198
|
+
"comparison",
|
|
1199
|
+
data["comparison"].get("next_poll_after_ms"),
|
|
1200
|
+
retry_after,
|
|
1201
|
+
)
|
|
1202
|
+
return self._comparison(data, resource_id)
|
|
1203
|
+
except ValueError:
|
|
1204
|
+
error = TldwError(
|
|
1205
|
+
"API returned an invalid accepted comparison.", code="INVALID_RESPONSE"
|
|
1206
|
+
)
|
|
1207
|
+
error.idempotency_key = stable_key
|
|
1208
|
+
raise error from None
|
|
1209
|
+
except TldwError as error:
|
|
1210
|
+
error.idempotency_key = stable_key
|
|
1211
|
+
raise
|
|
1212
|
+
|
|
1213
|
+
def get_comparison(self, comparison_id, *, cancel_event=None):
|
|
1214
|
+
comparison_id = _identifier(comparison_id)
|
|
1215
|
+
data, _ = self._request(
|
|
1216
|
+
"GET", f"/v1/comparisons/{comparison_id}", cancel_event=cancel_event
|
|
1217
|
+
)
|
|
1218
|
+
return self._comparison(data, comparison_id)
|
|
1219
|
+
|
|
1220
|
+
def wait_for_comparison(self, comparison_id, **options):
|
|
1221
|
+
return self._wait(_identifier(comparison_id), "comparison", **options)
|