transcriptdock 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.
@@ -0,0 +1,221 @@
1
+ """Transcript Dock public API client. No runtime dependencies."""
2
+
3
+ import ipaddress
4
+ import json
5
+ import math
6
+ import re
7
+ import time
8
+ from urllib.error import HTTPError, URLError
9
+ from urllib.parse import quote, unquote, urlencode, urlsplit
10
+ from urllib.request import HTTPRedirectHandler, Request, build_opener
11
+
12
+ __version__ = "0.1.0"
13
+
14
+
15
+ class ApiError(Exception):
16
+ """An API or transport failure. Requests are never automatically retried."""
17
+
18
+ def __init__(self, code, message, *, status=None, retryable=False,
19
+ request_id=None, retry_after_seconds=None):
20
+ super().__init__(message)
21
+ self.code = code
22
+ self.status = status
23
+ self.retryable = retryable
24
+ self.request_id = request_id
25
+ self.retry_after_seconds = retry_after_seconds
26
+
27
+
28
+ class JobTimeoutError(TimeoutError):
29
+ """Polling expired; job contains the last observed response."""
30
+
31
+ def __init__(self, job):
32
+ super().__init__("The job is still running. Check it again using its ID.")
33
+ self.job = job
34
+
35
+
36
+ class _NoRedirect(HTTPRedirectHandler):
37
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
38
+ # Keep bearer credentials on the configured origin, including for GETs.
39
+ return None
40
+
41
+
42
+ def _positive(value, name):
43
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0:
44
+ raise ValueError(f"{name} must be a finite positive number.")
45
+ return value
46
+
47
+
48
+ def _id(value):
49
+ if not isinstance(value, str) or not value or value in (".", ".."):
50
+ raise ValueError("Provide a nonempty ID.")
51
+ return quote(value, safe="")
52
+
53
+
54
+ class TranscriptDock:
55
+ """Synchronous API client. Responses retain every field returned by the API."""
56
+
57
+ def __init__(self, api_key, *, base_url="https://www.transcriptdock.com", timeout=30):
58
+ if not isinstance(api_key, str) or not api_key or any(ord(c) < 33 or ord(c) > 126 for c in api_key):
59
+ raise ValueError("Provide an API key without whitespace or control characters.")
60
+ parsed = urlsplit(base_url)
61
+ try:
62
+ loopback = ipaddress.ip_address(parsed.hostname or "").is_loopback
63
+ except ValueError:
64
+ loopback = parsed.hostname == "localhost"
65
+ if (parsed.scheme != "https" and not (parsed.scheme == "http" and loopback)) or not parsed.hostname:
66
+ raise ValueError("Use an HTTPS origin, or HTTP on loopback for local development.")
67
+ if parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path not in ("", "/"):
68
+ raise ValueError("The API origin cannot contain credentials, a path, query or fragment.")
69
+ # Validate the port before any request is made.
70
+ parsed.port
71
+ self._base_url = base_url.rstrip("/")
72
+ self._api_key = api_key
73
+ self.timeout = _positive(timeout, "timeout")
74
+ self._opener = build_opener(_NoRedirect())
75
+
76
+ def _redact(self, value):
77
+ value = str(value).replace(self._api_key, "[REDACTED]")
78
+ return re.sub(r"td_live_[A-Za-z0-9_-]+|Bearer\s+\S+", "[REDACTED]", value, flags=re.I)
79
+
80
+ def request(self, method, path, *, body=None, query=None, idempotency_key=None, text=False, timeout=None):
81
+ """Call a public /v1 endpoint; no redirects or automatic retries."""
82
+ if not isinstance(method, str):
83
+ raise ValueError("Provide an HTTP method.")
84
+ method = method.upper()
85
+ if method not in ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"):
86
+ raise ValueError("Provide a supported HTTP method.")
87
+ if not isinstance(path, str) or not path.startswith("/v1/") or any(c in path for c in ("?", "#", "\\")):
88
+ raise ValueError("Use a /v1/ path and pass query parameters separately.")
89
+ if any(part in (".", "..") for part in unquote(path).split("/")):
90
+ raise ValueError("API paths cannot contain traversal segments.")
91
+ needs_key = method == "POST" and (path in ("/v1/jobs", "/v1/batches", "/v1/video-discoveries", "/v1/language-discoveries") or path.endswith("/retry"))
92
+ if needs_key and idempotency_key is None:
93
+ raise ValueError("Provide an idempotency key for this submission.")
94
+ headers = {"Authorization": f"Bearer {self._api_key}", "Accept": "application/json",
95
+ "User-Agent": f"transcriptdock-python/{__version__}"}
96
+ if idempotency_key is not None:
97
+ if not isinstance(idempotency_key, str) or not re.fullmatch(r"[ -~]{8,128}", idempotency_key):
98
+ raise ValueError("Idempotency keys must contain 8 to 128 printable ASCII characters.")
99
+ headers["Idempotency-Key"] = idempotency_key
100
+ data = None
101
+ if body is not None:
102
+ data = json.dumps(body, allow_nan=False).encode("utf-8")
103
+ headers["Content-Type"] = "application/json"
104
+ url = self._base_url + path
105
+ if query:
106
+ url += "?" + urlencode({k: v for k, v in query.items() if v is not None})
107
+ request = Request(url, data=data, headers=headers, method=method)
108
+ try:
109
+ with self._opener.open(request, timeout=self.timeout if timeout is None else _positive(timeout, "timeout")) as response:
110
+ raw = response.read().decode("utf-8")
111
+ except HTTPError as exc:
112
+ try:
113
+ with exc:
114
+ envelope = json.loads(exc.read().decode("utf-8"))
115
+ except (ValueError, UnicodeError):
116
+ envelope = {}
117
+ envelope = envelope if isinstance(envelope, dict) else {}
118
+ error = envelope.get("error")
119
+ error = error if isinstance(error, dict) else {}
120
+ raise ApiError(self._redact(error.get("code", "HTTP_ERROR")),
121
+ self._redact(error.get("message") or f"Request failed with status {exc.code}."),
122
+ status=exc.code, retryable=bool(error.get("retryable", exc.code == 429 or exc.code >= 500)),
123
+ request_id=self._redact(envelope["request_id"]) if envelope.get("request_id") else None,
124
+ retry_after_seconds=error.get("retry_after_seconds")) from None
125
+ except (URLError, TimeoutError, OSError):
126
+ raise ApiError("NETWORK_ERROR", "Could not reach the API. Check the job before retrying a submission.", retryable=True) from None
127
+ except UnicodeError:
128
+ raise ApiError("INVALID_RESPONSE", "The API response is not UTF-8 text.") from None
129
+ if text:
130
+ return raw
131
+ try:
132
+ result = json.loads(raw)
133
+ except ValueError:
134
+ raise ApiError("INVALID_RESPONSE", "The API did not return JSON.") from None
135
+ if not isinstance(result, dict):
136
+ raise ApiError("INVALID_RESPONSE", "The API did not return a JSON object.")
137
+ return result
138
+
139
+ def capabilities(self):
140
+ return self.request("GET", "/v1/capabilities")
141
+
142
+ def create_job(self, body, *, idempotency_key):
143
+ return self.request("POST", "/v1/jobs", body=body, idempotency_key=idempotency_key)
144
+
145
+ def get_job(self, job_id, *, wait=0):
146
+ return self.request("GET", f"/v1/jobs/{_id(job_id)}", query={"wait": wait})
147
+
148
+ def list_jobs(self, *, limit=20, cursor=None):
149
+ return self.request("GET", "/v1/jobs", query={"limit": limit, "cursor": cursor})
150
+
151
+ def cancel_job(self, job_id):
152
+ return self.request("POST", f"/v1/jobs/{_id(job_id)}/cancel")
153
+
154
+ def retry_job(self, job_id, *, idempotency_key, max_credits=None):
155
+ body = {} if max_credits is None else {"max_credits": max_credits}
156
+ return self.request("POST", f"/v1/jobs/{_id(job_id)}/retry", body=body, idempotency_key=idempotency_key)
157
+
158
+ def get_transcript(self, transcript_id):
159
+ return self.request("GET", f"/v1/transcripts/{_id(transcript_id)}")
160
+
161
+ def export_transcript(self, transcript_id, format="txt"):
162
+ if format not in ("json", "txt", "srt", "vtt"):
163
+ raise ValueError("Choose json, txt, srt or vtt.")
164
+ return self.request("GET", f"/v1/transcripts/{_id(transcript_id)}/export", query={"format": format}, text=True)
165
+
166
+ def delete_transcript(self, transcript_id):
167
+ return self.request("DELETE", f"/v1/transcripts/{_id(transcript_id)}")
168
+
169
+ def create_batch(self, body, *, idempotency_key):
170
+ return self.request("POST", "/v1/batches", body=body, idempotency_key=idempotency_key)
171
+
172
+ def get_batch(self, batch_id):
173
+ return self.request("GET", f"/v1/batches/{_id(batch_id)}")
174
+
175
+ def create_video_discovery(self, body, *, idempotency_key):
176
+ return self.request("POST", "/v1/video-discoveries", body=body, idempotency_key=idempotency_key)
177
+
178
+ def get_video_discovery(self, discovery_id):
179
+ return self.request("GET", f"/v1/video-discoveries/{_id(discovery_id)}")
180
+
181
+ def create_language_discovery(self, body, *, idempotency_key):
182
+ return self.request("POST", "/v1/language-discoveries", body=body, idempotency_key=idempotency_key)
183
+
184
+ def get_language_discovery(self, discovery_id):
185
+ return self.request("GET", f"/v1/language-discoveries/{_id(discovery_id)}")
186
+
187
+ def create_upload(self, body):
188
+ """Request an upload URL. Upload file bytes separately without the API key."""
189
+ return self.request("POST", "/v1/uploads", body=body)
190
+
191
+ def complete_upload(self, upload_id):
192
+ return self.request("POST", f"/v1/uploads/{_id(upload_id)}/complete")
193
+
194
+ def usage(self):
195
+ return self.request("GET", "/v1/usage")
196
+
197
+ def list_webhook_endpoints(self):
198
+ return self.request("GET", "/v1/webhook-endpoints")
199
+
200
+ def create_webhook_endpoint(self, body):
201
+ return self.request("POST", "/v1/webhook-endpoints", body=body)
202
+
203
+ def delete_webhook_endpoint(self, endpoint_id):
204
+ return self.request("DELETE", f"/v1/webhook-endpoints/{_id(endpoint_id)}")
205
+
206
+ def wait_for_job(self, job_id, *, timeout=120, poll_interval=3):
207
+ """Return any terminal job; raise JobTimeoutError with the last job on timeout."""
208
+ deadline = time.monotonic() + _positive(timeout, "timeout")
209
+ _positive(poll_interval, "poll_interval")
210
+ job = None
211
+ while True:
212
+ remaining = deadline - time.monotonic()
213
+ if remaining <= 0:
214
+ raise JobTimeoutError(job)
215
+ job = self.request("GET", f"/v1/jobs/{_id(job_id)}", timeout=min(self.timeout, remaining))
216
+ if job.get("status") in ("succeeded", "failed", "cancelled"):
217
+ return job
218
+ remaining = deadline - time.monotonic()
219
+ if remaining <= 0:
220
+ raise JobTimeoutError(job)
221
+ time.sleep(min(poll_interval, remaining))
@@ -0,0 +1,150 @@
1
+ Metadata-Version: 2.5
2
+ Name: transcriptdock
3
+ Version: 0.1.0
4
+ Summary: Python client for the Transcript Dock video transcription API
5
+ Project-URL: Homepage, https://www.transcriptdock.com
6
+ Project-URL: Documentation, https://www.transcriptdock.com/docs
7
+ Project-URL: Repository, https://github.com/TranscriptDock/transcriptdock-python
8
+ Project-URL: Issues, https://github.com/TranscriptDock/transcriptdock-python/issues
9
+ Author-email: Transcript Dock <transcriptdock@volods.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: api,mcp,subtitles,tiktok,transcription,youtube
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Requires-Python: >=3.10
16
+ Description-Content-Type: text/markdown
17
+
18
+ # Transcript Dock for Python
19
+
20
+ A synchronous client for the [Transcript Dock API](https://www.transcriptdock.com/docs).
21
+ Python 3.10 or newer. No runtime dependencies.
22
+
23
+ Install from PyPI:
24
+
25
+ ```sh
26
+ python -m pip install transcriptdock
27
+ ```
28
+
29
+ For local development, use `python -m pip install .` from this directory.
30
+
31
+ ## Submit and read a transcript
32
+
33
+ Set `TRANSCRIPTDOCK_API_KEY` in your environment. Keep it out of source control.
34
+
35
+ ```python
36
+ import os
37
+ from uuid import uuid4
38
+ from transcriptdock import TranscriptDock, JobTimeoutError
39
+
40
+ client = TranscriptDock(os.environ["TRANSCRIPTDOCK_API_KEY"])
41
+ print(client.capabilities())
42
+
43
+ # Generate once per intended operation. Save this key and the returned job ID.
44
+ # Reuse the same key and exact body if a submission's response is lost.
45
+ submission_key = str(uuid4())
46
+ job = client.create_job(
47
+ {"source": {"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"},
48
+ "mode": "captions_only", "max_credits": 1},
49
+ idempotency_key=submission_key,
50
+ )
51
+ try:
52
+ job = client.wait_for_job(job["id"], timeout=120)
53
+ except JobTimeoutError as error:
54
+ print("Still running", error.job)
55
+ else:
56
+ if job["status"] == "succeeded":
57
+ transcript = client.get_transcript(job["result_id"])
58
+ print(transcript)
59
+ print(client.export_transcript(job["result_id"], "srt"))
60
+ else:
61
+ print(job["status"], job.get("error"))
62
+ ```
63
+
64
+ Creating a job can spend credits. This example caps the request at one credit
65
+ and requests captions only. It does not enable AI fallback. Check capabilities
66
+ and your plan before selecting other modes. Never blindly resubmit after a
67
+ network failure: check the job ID or replay the same body with the same
68
+ idempotency key. The client does not retry requests automatically.
69
+
70
+ ## Supported operations
71
+
72
+ All responses are dictionaries retaining the API's fields. Request bodies use
73
+ the API's exact field names; the server validates supported options. See the
74
+ [OpenAPI definition](https://www.transcriptdock.com/docs/openapi.yaml) for bodies.
75
+
76
+ * `capabilities()`, `usage()`
77
+ * `create_job(body, idempotency_key=...)`, `get_job(id, wait=0)`,
78
+ `list_jobs(limit=20, cursor=None)`, `cancel_job(id)`,
79
+ `retry_job(id, idempotency_key=..., max_credits=None)`, `wait_for_job(id, ...)`
80
+ * `get_transcript(id)`, `export_transcript(id, format="txt")`, `delete_transcript(id)`
81
+ * `create_batch(body, idempotency_key=...)`, `get_batch(id)`
82
+ * `create_video_discovery(body, idempotency_key=...)`, `get_video_discovery(id)`
83
+ * `create_language_discovery(body, idempotency_key=...)`, `get_language_discovery(id)`
84
+ * `create_upload(body)`, `complete_upload(id)`
85
+ * `list_webhook_endpoints()`, `create_webhook_endpoint(body)`, `delete_webhook_endpoint(id)`
86
+
87
+ `export_transcript` returns a string for JSON, TXT, SRT and VTT, preserving the
88
+ export response. `get_transcript` preserves raw timing fields without converting
89
+ units. `wait_for_job` returns succeeded, failed or cancelled jobs; inspect status
90
+ before using `result_id`. Timeout leaves the job running and exposes its last
91
+ observed response as `JobTimeoutError.job` (or `None` if no poll was possible).
92
+
93
+ `create_upload` creates an upload slot only. Transfer bytes to the returned signed
94
+ upload URL separately following the API instructions, without sending your
95
+ Transcript Dock API key to the storage host. Then call `complete_upload`.
96
+ Webhook secrets are sensitive; store them privately and verify callback
97
+ signatures in your receiver according to the API documentation.
98
+
99
+ `request(method, "/v1/...", body=..., query=...)` supports public endpoints added
100
+ later. It rejects absolute URLs, traversal paths and redirects. Custom API
101
+ origins must use HTTPS, except loopback HTTP for local development. The default
102
+ origin is `https://www.transcriptdock.com`; the apex domain redirects.
103
+
104
+ ## Errors
105
+
106
+ ```python
107
+ from transcriptdock import ApiError
108
+
109
+ try:
110
+ jobs = client.list_jobs(limit=10)
111
+ except ApiError as error:
112
+ print(error.code, error.status, error.request_id)
113
+ # retryable and retry_after_seconds describe the API response.
114
+ # Decide whether to retry using the operation's idempotency rules.
115
+ ```
116
+
117
+ Transport failures use `NETWORK_ERROR`; malformed responses use `INVALID_RESPONSE`.
118
+ API messages redact bearer tokens. Error responses do not expose raw HTML pages.
119
+
120
+ ## Development and release
121
+
122
+ ```sh
123
+ PYTHONPATH=src python -m unittest discover -s tests -v
124
+ uv build
125
+ uvx --from twine==7.0.0 twine check dist/*
126
+ ```
127
+
128
+ Build backend versions are pinned in `pyproject.toml`; runtime dependencies are
129
+ empty. Tests use a loopback fixture server and spend no credits.
130
+
131
+ Before publishing, review artifacts, configure the Transcript Dock publisher on
132
+ PyPI, enable two factor authentication and use a scoped token or trusted
133
+ publishing. The founder handles registry sign-in. Upload only the reviewed
134
+ version with `uv publish`; verify the public package version afterward. Never
135
+ put registry or API credentials in this directory. A local build is not a release.
136
+
137
+
138
+ For the first release, add a pending trusted publisher in PyPI with:
139
+
140
+ | Field | Value |
141
+ | --- | --- |
142
+ | Project name | `transcriptdock` |
143
+ | Owner | `TranscriptDock` |
144
+ | Repository | `transcriptdock-python` |
145
+ | Workflow filename | `release.yml` |
146
+ | Environment | `pypi` |
147
+
148
+ Then run the GitHub Actions workflow on `main` with `publish` enabled. The
149
+ normal push workflow checks and builds only. Sign-in and initial publisher
150
+ registration are still required; no PyPI account has been created for you.
@@ -0,0 +1,5 @@
1
+ transcriptdock/__init__.py,sha256=ABS-yKAdHxz_8hqmtjnk1bY5rkwD0BkeXK1Daa3xF_0,10711
2
+ transcriptdock-0.1.0.dist-info/METADATA,sha256=rgLsn3oqoW7qI1gvco474UztrgxIKaCT7iPzr31pGFA,6181
3
+ transcriptdock-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
4
+ transcriptdock-0.1.0.dist-info/licenses/LICENSE,sha256=PlrCALGeq75kxiCPnmB1r7omsS774wwjE7djN0DfMQw,1072
5
+ transcriptdock-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Transcript Dock
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.