transcriptdock 0.1.0__tar.gz

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,53 @@
1
+ name: Check and release Python package
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ pull_request:
6
+ workflow_dispatch:
7
+ inputs:
8
+ publish:
9
+ description: Publish the checked version to PyPI
10
+ type: boolean
11
+ default: false
12
+ permissions:
13
+ contents: read
14
+ jobs:
15
+ test:
16
+ runs-on: ubuntu-latest
17
+ strategy:
18
+ matrix:
19
+ python: ['3.10', '3.12', '3.14']
20
+ steps:
21
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
22
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
23
+ with:
24
+ python-version: ${{ matrix.python }}
25
+ - run: PYTHONPATH=src python -W error::ResourceWarning -m unittest discover -s tests -v
26
+ build:
27
+ needs: test
28
+ runs-on: ubuntu-latest
29
+ steps:
30
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262
31
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
32
+ with:
33
+ python-version: '3.12'
34
+ - run: python -m pip install build==1.6.1 twine==7.0.0
35
+ - run: python -m build
36
+ - run: python -m twine check dist/*
37
+ - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
38
+ with:
39
+ name: distributions
40
+ path: dist/*
41
+ publish:
42
+ if: github.event_name == 'workflow_dispatch' && inputs.publish && github.ref == 'refs/heads/main'
43
+ needs: build
44
+ runs-on: ubuntu-latest
45
+ environment: pypi
46
+ permissions:
47
+ id-token: write
48
+ steps:
49
+ - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
50
+ with:
51
+ name: distributions
52
+ path: dist/
53
+ - uses: pypa/gh-action-pypi-publish@7245906fb61fa33a13554f9d5fb856fe61758086
@@ -0,0 +1,4 @@
1
+ dist/
2
+ __pycache__/
3
+ *.pyc
4
+ .venv/
@@ -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.
@@ -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,133 @@
1
+ # Transcript Dock for Python
2
+
3
+ A synchronous client for the [Transcript Dock API](https://www.transcriptdock.com/docs).
4
+ Python 3.10 or newer. No runtime dependencies.
5
+
6
+ Install from PyPI:
7
+
8
+ ```sh
9
+ python -m pip install transcriptdock
10
+ ```
11
+
12
+ For local development, use `python -m pip install .` from this directory.
13
+
14
+ ## Submit and read a transcript
15
+
16
+ Set `TRANSCRIPTDOCK_API_KEY` in your environment. Keep it out of source control.
17
+
18
+ ```python
19
+ import os
20
+ from uuid import uuid4
21
+ from transcriptdock import TranscriptDock, JobTimeoutError
22
+
23
+ client = TranscriptDock(os.environ["TRANSCRIPTDOCK_API_KEY"])
24
+ print(client.capabilities())
25
+
26
+ # Generate once per intended operation. Save this key and the returned job ID.
27
+ # Reuse the same key and exact body if a submission's response is lost.
28
+ submission_key = str(uuid4())
29
+ job = client.create_job(
30
+ {"source": {"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ"},
31
+ "mode": "captions_only", "max_credits": 1},
32
+ idempotency_key=submission_key,
33
+ )
34
+ try:
35
+ job = client.wait_for_job(job["id"], timeout=120)
36
+ except JobTimeoutError as error:
37
+ print("Still running", error.job)
38
+ else:
39
+ if job["status"] == "succeeded":
40
+ transcript = client.get_transcript(job["result_id"])
41
+ print(transcript)
42
+ print(client.export_transcript(job["result_id"], "srt"))
43
+ else:
44
+ print(job["status"], job.get("error"))
45
+ ```
46
+
47
+ Creating a job can spend credits. This example caps the request at one credit
48
+ and requests captions only. It does not enable AI fallback. Check capabilities
49
+ and your plan before selecting other modes. Never blindly resubmit after a
50
+ network failure: check the job ID or replay the same body with the same
51
+ idempotency key. The client does not retry requests automatically.
52
+
53
+ ## Supported operations
54
+
55
+ All responses are dictionaries retaining the API's fields. Request bodies use
56
+ the API's exact field names; the server validates supported options. See the
57
+ [OpenAPI definition](https://www.transcriptdock.com/docs/openapi.yaml) for bodies.
58
+
59
+ * `capabilities()`, `usage()`
60
+ * `create_job(body, idempotency_key=...)`, `get_job(id, wait=0)`,
61
+ `list_jobs(limit=20, cursor=None)`, `cancel_job(id)`,
62
+ `retry_job(id, idempotency_key=..., max_credits=None)`, `wait_for_job(id, ...)`
63
+ * `get_transcript(id)`, `export_transcript(id, format="txt")`, `delete_transcript(id)`
64
+ * `create_batch(body, idempotency_key=...)`, `get_batch(id)`
65
+ * `create_video_discovery(body, idempotency_key=...)`, `get_video_discovery(id)`
66
+ * `create_language_discovery(body, idempotency_key=...)`, `get_language_discovery(id)`
67
+ * `create_upload(body)`, `complete_upload(id)`
68
+ * `list_webhook_endpoints()`, `create_webhook_endpoint(body)`, `delete_webhook_endpoint(id)`
69
+
70
+ `export_transcript` returns a string for JSON, TXT, SRT and VTT, preserving the
71
+ export response. `get_transcript` preserves raw timing fields without converting
72
+ units. `wait_for_job` returns succeeded, failed or cancelled jobs; inspect status
73
+ before using `result_id`. Timeout leaves the job running and exposes its last
74
+ observed response as `JobTimeoutError.job` (or `None` if no poll was possible).
75
+
76
+ `create_upload` creates an upload slot only. Transfer bytes to the returned signed
77
+ upload URL separately following the API instructions, without sending your
78
+ Transcript Dock API key to the storage host. Then call `complete_upload`.
79
+ Webhook secrets are sensitive; store them privately and verify callback
80
+ signatures in your receiver according to the API documentation.
81
+
82
+ `request(method, "/v1/...", body=..., query=...)` supports public endpoints added
83
+ later. It rejects absolute URLs, traversal paths and redirects. Custom API
84
+ origins must use HTTPS, except loopback HTTP for local development. The default
85
+ origin is `https://www.transcriptdock.com`; the apex domain redirects.
86
+
87
+ ## Errors
88
+
89
+ ```python
90
+ from transcriptdock import ApiError
91
+
92
+ try:
93
+ jobs = client.list_jobs(limit=10)
94
+ except ApiError as error:
95
+ print(error.code, error.status, error.request_id)
96
+ # retryable and retry_after_seconds describe the API response.
97
+ # Decide whether to retry using the operation's idempotency rules.
98
+ ```
99
+
100
+ Transport failures use `NETWORK_ERROR`; malformed responses use `INVALID_RESPONSE`.
101
+ API messages redact bearer tokens. Error responses do not expose raw HTML pages.
102
+
103
+ ## Development and release
104
+
105
+ ```sh
106
+ PYTHONPATH=src python -m unittest discover -s tests -v
107
+ uv build
108
+ uvx --from twine==7.0.0 twine check dist/*
109
+ ```
110
+
111
+ Build backend versions are pinned in `pyproject.toml`; runtime dependencies are
112
+ empty. Tests use a loopback fixture server and spend no credits.
113
+
114
+ Before publishing, review artifacts, configure the Transcript Dock publisher on
115
+ PyPI, enable two factor authentication and use a scoped token or trusted
116
+ publishing. The founder handles registry sign-in. Upload only the reviewed
117
+ version with `uv publish`; verify the public package version afterward. Never
118
+ put registry or API credentials in this directory. A local build is not a release.
119
+
120
+
121
+ For the first release, add a pending trusted publisher in PyPI with:
122
+
123
+ | Field | Value |
124
+ | --- | --- |
125
+ | Project name | `transcriptdock` |
126
+ | Owner | `TranscriptDock` |
127
+ | Repository | `transcriptdock-python` |
128
+ | Workflow filename | `release.yml` |
129
+ | Environment | `pypi` |
130
+
131
+ Then run the GitHub Actions workflow on `main` with `publish` enabled. The
132
+ normal push workflow checks and builds only. Sign-in and initial publisher
133
+ registration are still required; no PyPI account has been created for you.
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["hatchling==1.33.0"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "transcriptdock"
7
+ version = "0.1.0"
8
+ description = "Python client for the Transcript Dock video transcription API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{name = "Transcript Dock", email = "transcriptdock@volods.com"}]
14
+ keywords = ["transcription", "youtube", "tiktok", "subtitles", "api", "mcp"]
15
+ classifiers = ["Programming Language :: Python :: 3", "Operating System :: OS Independent"]
16
+ dependencies = []
17
+
18
+ [project.urls]
19
+ Homepage = "https://www.transcriptdock.com"
20
+ Documentation = "https://www.transcriptdock.com/docs"
21
+ Repository = "https://github.com/TranscriptDock/transcriptdock-python"
22
+ Issues = "https://github.com/TranscriptDock/transcriptdock-python/issues"
23
+
24
+ [tool.hatch.build.targets.wheel]
25
+ packages = ["src/transcriptdock"]
@@ -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,152 @@
1
+ """Local fixtures only. No production traffic or credits."""
2
+ import json
3
+ import threading
4
+ import unittest
5
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
6
+ from unittest.mock import patch
7
+
8
+ from transcriptdock import ApiError, JobTimeoutError, TranscriptDock
9
+
10
+ KEY = "td_live_local_fixture_only"
11
+
12
+
13
+ class Handler(BaseHTTPRequestHandler):
14
+ calls = []
15
+
16
+ def log_message(self, *args):
17
+ pass
18
+
19
+ def do_GET(self):
20
+ self.respond()
21
+
22
+ def do_POST(self):
23
+ self.respond()
24
+
25
+ def respond(self):
26
+ body = self.rfile.read(int(self.headers.get("Content-Length", "0")))
27
+ self.calls.append((self.command, self.path, dict(self.headers), body))
28
+ code, headers = 200, {}
29
+ payload = {"id": "fixture-job", "status": "succeeded", "future_field": 42}
30
+ if self.path == "/v1/redirect":
31
+ code, headers, payload = 302, {"Location": "/v1/leak"}, {}
32
+ elif self.path == "/v1/rate-limit":
33
+ code = 429
34
+ payload = {"error": {"code": "RATE_LIMITED", "message": KEY,
35
+ "retryable": True, "retry_after_seconds": 4}, "request_id": "fixture-request"}
36
+ elif self.path in ("/v1/budget", "/v1/conflict", "/v1/jobs/not-owned"):
37
+ code, label = {"/v1/budget": (402, "BUDGET_EXCEEDED"), "/v1/conflict": (409, "IDEMPOTENCY_CONFLICT"), "/v1/jobs/not-owned": (404, "NOT_FOUND")}[self.path]
38
+ payload = {"error": {"code": label, "message": "Fixture rejection", "retryable": False}}
39
+ elif self.path == "/v1/gateway":
40
+ code, payload = 502, b"<html>upstream failed</html>"
41
+ elif self.path == "/v1/malformed":
42
+ payload = b"not json"
43
+ elif self.path.endswith("/export?format=srt"):
44
+ payload = b"1\n00:00:00,000 --> 00:00:01,000\nFixture\n"
45
+ raw = payload if isinstance(payload, bytes) else json.dumps(payload).encode()
46
+ self.send_response(code)
47
+ for k, v in headers.items():
48
+ self.send_header(k, v)
49
+ self.send_header("Content-Length", str(len(raw)))
50
+ self.end_headers()
51
+ self.wfile.write(raw)
52
+
53
+
54
+ class ClientTests(unittest.TestCase):
55
+ @classmethod
56
+ def setUpClass(cls):
57
+ cls.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
58
+ cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
59
+ cls.thread.start()
60
+ cls.client = TranscriptDock(KEY, base_url=f"http://127.0.0.1:{cls.server.server_port}")
61
+
62
+ @classmethod
63
+ def tearDownClass(cls):
64
+ cls.server.shutdown()
65
+ cls.server.server_close()
66
+ cls.thread.join()
67
+
68
+ def setUp(self):
69
+ Handler.calls.clear()
70
+
71
+ def test_submission_preserves_body_key_and_unknown_response_fields(self):
72
+ body = {"source": {"url": "https://www.youtube.com/watch?v=fixture"},
73
+ "mode": "captions_only", "max_credits": 1}
74
+ for _ in range(2):
75
+ result = self.client.create_job(body, idempotency_key="fixture-replay-01")
76
+ self.assertEqual(result["future_field"], 42)
77
+ self.assertEqual(len(Handler.calls), 2)
78
+ for method, path, headers, raw in Handler.calls:
79
+ self.assertEqual((method, path), ("POST", "/v1/jobs"))
80
+ self.assertEqual(headers["Authorization"], "Bearer " + KEY)
81
+ self.assertEqual(headers["Idempotency-Key"], "fixture-replay-01")
82
+ self.assertEqual(json.loads(raw), body)
83
+
84
+ def test_no_automatic_retry_and_safe_errors(self):
85
+ with self.assertRaises(ApiError) as ctx:
86
+ self.client.request("GET", "/v1/rate-limit")
87
+ self.assertEqual(len(Handler.calls), 1)
88
+ self.assertEqual(ctx.exception.code, "RATE_LIMITED")
89
+ self.assertEqual(ctx.exception.status, 429)
90
+ self.assertEqual(ctx.exception.retry_after_seconds, 4)
91
+ self.assertTrue(ctx.exception.retryable)
92
+ self.assertNotIn(KEY, str(ctx.exception))
93
+ with self.assertRaises(ApiError) as ctx:
94
+ self.client.request("GET", "/v1/gateway")
95
+ self.assertNotIn("html", str(ctx.exception))
96
+ with self.assertRaises(ApiError) as ctx:
97
+ self.client.request("GET", "/v1/malformed")
98
+ self.assertEqual(ctx.exception.code, "INVALID_RESPONSE")
99
+
100
+ def test_budget_conflict_and_inaccessible_job_errors_are_not_retried(self):
101
+ for path, status, code in [("/v1/budget", 402, "BUDGET_EXCEEDED"),
102
+ ("/v1/conflict", 409, "IDEMPOTENCY_CONFLICT"),
103
+ ("/v1/jobs/not-owned", 404, "NOT_FOUND")]:
104
+ with self.assertRaises(ApiError) as ctx:
105
+ self.client.request("GET", path)
106
+ self.assertEqual((ctx.exception.status, ctx.exception.code), (status, code))
107
+ self.assertFalse(ctx.exception.retryable)
108
+ self.assertEqual(len(Handler.calls), 3)
109
+
110
+ def test_redirect_never_followed(self):
111
+ with self.assertRaises(ApiError) as ctx:
112
+ self.client.request("GET", "/v1/redirect")
113
+ self.assertEqual(ctx.exception.status, 302)
114
+ self.assertEqual([x[1] for x in Handler.calls], ["/v1/redirect"])
115
+
116
+ def test_query_and_id_encoding_and_raw_export(self):
117
+ self.client.list_jobs(cursor="a/b?c&d", limit=7)
118
+ self.assertIn("cursor=a%2Fb%3Fc%26d", Handler.calls[-1][1])
119
+ self.client.get_job("id/other?x")
120
+ self.assertTrue(Handler.calls[-1][1].startswith("/v1/jobs/id%2Fother%3Fx"))
121
+ self.assertIn("Fixture", self.client.export_transcript("fixture", "srt"))
122
+
123
+ def test_rejects_credential_and_submission_hazards_before_network(self):
124
+ for base in ("http://example.com", "https://user:password@example.com", "https://example.com/path", "https://example.com?x=1"):
125
+ with self.assertRaises(ValueError):
126
+ TranscriptDock(KEY, base_url=base)
127
+ with self.assertRaises(ValueError):
128
+ TranscriptDock("bad\nkey")
129
+ for key in (None, "short", "has\nnewline"):
130
+ with self.assertRaises(ValueError):
131
+ self.client.create_job({}, idempotency_key=key)
132
+ for path in ("https://example.com", "/v1/../secret", "/v1/%2e%2e/secret", "/v1/jobs?x=1"):
133
+ with self.assertRaises(ValueError):
134
+ self.client.request("GET", path)
135
+ with self.assertRaises(ValueError):
136
+ self.client.request("post", "/v1/jobs", body={})
137
+ self.assertEqual(Handler.calls, [])
138
+
139
+ def test_polling_terminal_failure_and_timeout_preserve_state(self):
140
+ with patch.object(self.client, "request", return_value={"id": "fixture", "status": "failed"}):
141
+ self.assertEqual(self.client.wait_for_job("fixture")["status"], "failed")
142
+ job = {"id": "fixture", "status": "processing"}
143
+ with patch.object(self.client, "request", return_value=job), patch("transcriptdock.time.monotonic", side_effect=[0, 0, 2]):
144
+ with self.assertRaises(JobTimeoutError) as ctx:
145
+ self.client.wait_for_job("fixture", timeout=1)
146
+ self.assertEqual(ctx.exception.job, job)
147
+ with self.assertRaises(ValueError):
148
+ self.client.wait_for_job("fixture", timeout=float("nan"))
149
+
150
+
151
+ if __name__ == "__main__":
152
+ unittest.main()
@@ -0,0 +1,8 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.10"
4
+
5
+ [[package]]
6
+ name = "transcriptdock"
7
+ version = "0.1.0"
8
+ source = { editable = "." }