runconvert 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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 runconvert
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,128 @@
1
+ Metadata-Version: 2.4
2
+ Name: runconvert
3
+ Version: 0.1.0
4
+ Summary: Official RunConvert SDK for Python: convert files between 240+ formats
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://www.runconvert.com
7
+ Project-URL: Documentation, https://www.runconvert.com/docs
8
+ Project-URL: Source, https://github.com/runconvert/runconvert-python
9
+ Keywords: runconvert,file converter,convert,pdf,video,image,api,sdk
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Typing :: Typed
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: license-file
17
+
18
+ # RunConvert SDK for Python
19
+
20
+ Convert files between 240+ formats (3,000+ conversions) with the [RunConvert API](https://www.runconvert.com/docs):
21
+ documents, images, video, audio, ebooks, archives, fonts and CAD. Python 3.10+, no dependencies.
22
+
23
+ ```bash
24
+ pip install runconvert
25
+ ```
26
+
27
+ ```python
28
+ from runconvert import RunConvert
29
+
30
+ rc = RunConvert() # reads RUNCONVERT_API_KEY
31
+
32
+ # One call: upload, convert, wait, download.
33
+ result = rc.convert("report.docx", "pdf", out_dir="out")
34
+ print(result.saved) # ['out/report.pdf']
35
+ ```
36
+
37
+ Get an API key in **Dashboard → API Keys** on [runconvert.com](https://www.runconvert.com). Keep keys on the server.
38
+
39
+ ## Convert
40
+
41
+ ```python
42
+ # From a URL (imported by RunConvert, nothing to upload)
43
+ rc.convert("https://example.com/talk.mp4", "mp3", options={"audioBitrate": "192k"})
44
+
45
+ # From bytes or a file object: give a filename (or from_)
46
+ rc.convert(data, "jpg", filename="photo.heic", out_dir="out")
47
+
48
+ # Progress and a time limit (seconds)
49
+ rc.convert("movie.mov", "mp4", out_dir="out", on_update=lambda job: print(job["status"]), timeout=600)
50
+ ```
51
+
52
+ `convert` returns `ConvertResult(job, files, saved)`. Options for a conversion come from
53
+ `rc.operations.for_pair("mp4", "mp3")`.
54
+
55
+ ## Jobs with your own tasks
56
+
57
+ ```python
58
+ from runconvert import Jobs
59
+
60
+ # Merge three PDFs into one
61
+ job = rc.jobs.create({
62
+ "a": {"operation": "import/url", "source_url": "https://example.com/cover.pdf"},
63
+ "b": {"operation": "import/url", "source_url": "https://example.com/body.pdf"},
64
+ "merge": {"operation": "convert", "input": ["a", "b"], "input_format": "pdf", "output_format": "pdf"},
65
+ "export": {"operation": "export/url", "input": "merge"},
66
+ }, tag="merge")
67
+ done = rc.jobs.wait(job["id"])
68
+ for f in Jobs.exported_files(done):
69
+ rc.files.download(f, "out")
70
+
71
+ # Upload your own file
72
+ job = rc.jobs.create({
73
+ "upload": {"operation": "import/direct/upload"},
74
+ "convert": {"operation": "convert", "input": "upload", "input_format": "png", "output_format": "webp", "image_quality": 80},
75
+ "export": {"operation": "export/url", "input": "convert"},
76
+ })
77
+ upload_task = next(t for t in job["tasks"] if t["name"] == "upload")
78
+ rc.files.upload(upload_task, "image.png")
79
+ ```
80
+
81
+ | Method | What it does |
82
+ |---|---|
83
+ | `rc.jobs.create(tasks, tag=, metadata=)` | Create a job (returned with its tasks) |
84
+ | `rc.jobs.get(id)` / `rc.jobs.list(status=, tag=, page=, per_page=)` | A job / one page `{"data", "meta"}` |
85
+ | `rc.jobs.all(...)` | Every job: `for job in rc.jobs.all()` |
86
+ | `rc.jobs.wait(id, timeout=, on_update=, raise_on_error=)` | Poll until `finished` or `error` |
87
+ | `rc.jobs.cancel(id)` / `rc.jobs.delete(id)` | Stop pending tasks / delete |
88
+ | `rc.files.upload(task, file)` / `rc.files.download(file, dir)` | Upload to a task / save a result |
89
+ | `rc.operations.list(category=)` / `for_input("mp4")` / `for_pair("mp4", "mp3")` | Formats, targets, engines (no key needed) |
90
+ | `rc.me()` | Account, team and credit balance |
91
+
92
+ ## Webhooks
93
+
94
+ ```python
95
+ from runconvert import construct_event
96
+
97
+ @app.post("/webhooks/runconvert") # Flask
98
+ def runconvert_webhook():
99
+ try:
100
+ event = construct_event(request.get_data(), request.headers.get("X-RunConvert-Signature"), os.environ["RUNCONVERT_WEBHOOK_SECRET"])
101
+ except ValueError:
102
+ return "", 400
103
+ if event["event"] == "job.finished":
104
+ print("done", event["job"]["id"])
105
+ return "", 200
106
+ ```
107
+
108
+ ## Errors
109
+
110
+ | Error | When |
111
+ |---|---|
112
+ | `RunConvertError` | The API said no: `.status`, `.code` (e.g. `BILLING_INSUFFICIENT_CREDITS`), `.message` |
113
+ | `JobFailedError` | The job ended in `error`; `.task` has `code`, `reason`, `message` |
114
+ | `WaitTimeoutError` | `wait` ran past its `timeout` |
115
+
116
+ Network errors, `429` and `5xx` are retried twice with backoff (`max_retries`). Creating a job is only
117
+ retried when the request never reached the API, so a job is never created twice.
118
+
119
+ ## Development
120
+
121
+ ```bash
122
+ PYTHONPATH=src python3 -m unittest tests.test_unit # no network
123
+ RUNCONVERT_API_KEY=rc_live_… PYTHONPATH=src python3 -m unittest tests.test_live # real API, about 2 credits
124
+ ```
125
+
126
+ ## License
127
+
128
+ MIT
@@ -0,0 +1,111 @@
1
+ # RunConvert SDK for Python
2
+
3
+ Convert files between 240+ formats (3,000+ conversions) with the [RunConvert API](https://www.runconvert.com/docs):
4
+ documents, images, video, audio, ebooks, archives, fonts and CAD. Python 3.10+, no dependencies.
5
+
6
+ ```bash
7
+ pip install runconvert
8
+ ```
9
+
10
+ ```python
11
+ from runconvert import RunConvert
12
+
13
+ rc = RunConvert() # reads RUNCONVERT_API_KEY
14
+
15
+ # One call: upload, convert, wait, download.
16
+ result = rc.convert("report.docx", "pdf", out_dir="out")
17
+ print(result.saved) # ['out/report.pdf']
18
+ ```
19
+
20
+ Get an API key in **Dashboard → API Keys** on [runconvert.com](https://www.runconvert.com). Keep keys on the server.
21
+
22
+ ## Convert
23
+
24
+ ```python
25
+ # From a URL (imported by RunConvert, nothing to upload)
26
+ rc.convert("https://example.com/talk.mp4", "mp3", options={"audioBitrate": "192k"})
27
+
28
+ # From bytes or a file object: give a filename (or from_)
29
+ rc.convert(data, "jpg", filename="photo.heic", out_dir="out")
30
+
31
+ # Progress and a time limit (seconds)
32
+ rc.convert("movie.mov", "mp4", out_dir="out", on_update=lambda job: print(job["status"]), timeout=600)
33
+ ```
34
+
35
+ `convert` returns `ConvertResult(job, files, saved)`. Options for a conversion come from
36
+ `rc.operations.for_pair("mp4", "mp3")`.
37
+
38
+ ## Jobs with your own tasks
39
+
40
+ ```python
41
+ from runconvert import Jobs
42
+
43
+ # Merge three PDFs into one
44
+ job = rc.jobs.create({
45
+ "a": {"operation": "import/url", "source_url": "https://example.com/cover.pdf"},
46
+ "b": {"operation": "import/url", "source_url": "https://example.com/body.pdf"},
47
+ "merge": {"operation": "convert", "input": ["a", "b"], "input_format": "pdf", "output_format": "pdf"},
48
+ "export": {"operation": "export/url", "input": "merge"},
49
+ }, tag="merge")
50
+ done = rc.jobs.wait(job["id"])
51
+ for f in Jobs.exported_files(done):
52
+ rc.files.download(f, "out")
53
+
54
+ # Upload your own file
55
+ job = rc.jobs.create({
56
+ "upload": {"operation": "import/direct/upload"},
57
+ "convert": {"operation": "convert", "input": "upload", "input_format": "png", "output_format": "webp", "image_quality": 80},
58
+ "export": {"operation": "export/url", "input": "convert"},
59
+ })
60
+ upload_task = next(t for t in job["tasks"] if t["name"] == "upload")
61
+ rc.files.upload(upload_task, "image.png")
62
+ ```
63
+
64
+ | Method | What it does |
65
+ |---|---|
66
+ | `rc.jobs.create(tasks, tag=, metadata=)` | Create a job (returned with its tasks) |
67
+ | `rc.jobs.get(id)` / `rc.jobs.list(status=, tag=, page=, per_page=)` | A job / one page `{"data", "meta"}` |
68
+ | `rc.jobs.all(...)` | Every job: `for job in rc.jobs.all()` |
69
+ | `rc.jobs.wait(id, timeout=, on_update=, raise_on_error=)` | Poll until `finished` or `error` |
70
+ | `rc.jobs.cancel(id)` / `rc.jobs.delete(id)` | Stop pending tasks / delete |
71
+ | `rc.files.upload(task, file)` / `rc.files.download(file, dir)` | Upload to a task / save a result |
72
+ | `rc.operations.list(category=)` / `for_input("mp4")` / `for_pair("mp4", "mp3")` | Formats, targets, engines (no key needed) |
73
+ | `rc.me()` | Account, team and credit balance |
74
+
75
+ ## Webhooks
76
+
77
+ ```python
78
+ from runconvert import construct_event
79
+
80
+ @app.post("/webhooks/runconvert") # Flask
81
+ def runconvert_webhook():
82
+ try:
83
+ event = construct_event(request.get_data(), request.headers.get("X-RunConvert-Signature"), os.environ["RUNCONVERT_WEBHOOK_SECRET"])
84
+ except ValueError:
85
+ return "", 400
86
+ if event["event"] == "job.finished":
87
+ print("done", event["job"]["id"])
88
+ return "", 200
89
+ ```
90
+
91
+ ## Errors
92
+
93
+ | Error | When |
94
+ |---|---|
95
+ | `RunConvertError` | The API said no: `.status`, `.code` (e.g. `BILLING_INSUFFICIENT_CREDITS`), `.message` |
96
+ | `JobFailedError` | The job ended in `error`; `.task` has `code`, `reason`, `message` |
97
+ | `WaitTimeoutError` | `wait` ran past its `timeout` |
98
+
99
+ Network errors, `429` and `5xx` are retried twice with backoff (`max_retries`). Creating a job is only
100
+ retried when the request never reached the API, so a job is never created twice.
101
+
102
+ ## Development
103
+
104
+ ```bash
105
+ PYTHONPATH=src python3 -m unittest tests.test_unit # no network
106
+ RUNCONVERT_API_KEY=rc_live_… PYTHONPATH=src python3 -m unittest tests.test_live # real API, about 2 credits
107
+ ```
108
+
109
+ ## License
110
+
111
+ MIT
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "runconvert"
7
+ version = "0.1.0"
8
+ description = "Official RunConvert SDK for Python: convert files between 240+ formats"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ dependencies = []
14
+ keywords = ["runconvert", "file converter", "convert", "pdf", "video", "image", "api", "sdk"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Operating System :: OS Independent",
18
+ "Typing :: Typed",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://www.runconvert.com"
23
+ Documentation = "https://www.runconvert.com/docs"
24
+ Source = "https://github.com/runconvert/runconvert-python"
25
+
26
+ [tool.setuptools.packages.find]
27
+ where = ["src"]
28
+
29
+ [tool.setuptools.package-data]
30
+ runconvert = ["py.typed"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,19 @@
1
+ """Official RunConvert SDK for Python: convert files between 240+ formats."""
2
+
3
+ from .client import ConvertResult, Files, Jobs, Operations, RunConvert, __version__
4
+ from .errors import JobFailedError, RunConvertError, WaitTimeoutError
5
+ from .webhooks import construct_event, verify_signature
6
+
7
+ __all__ = [
8
+ "RunConvert",
9
+ "Jobs",
10
+ "Operations",
11
+ "Files",
12
+ "ConvertResult",
13
+ "RunConvertError",
14
+ "JobFailedError",
15
+ "WaitTimeoutError",
16
+ "verify_signature",
17
+ "construct_event",
18
+ "__version__",
19
+ ]
@@ -0,0 +1,344 @@
1
+ """The RunConvert API client (v3). Standard library only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import platform
8
+ import random
9
+ import shutil
10
+ import time
11
+ import urllib.error
12
+ import urllib.parse
13
+ import urllib.request
14
+ from dataclasses import dataclass, field
15
+ from typing import Any, BinaryIO, Callable, Dict, Iterator, List, Optional, Union
16
+
17
+ from . import webhooks as _webhooks
18
+ from .errors import JobFailedError, RunConvertError, WaitTimeoutError
19
+
20
+ __version__ = "0.1.0"
21
+
22
+ DEFAULT_BASE_URL = "https://api.runconvert.com/v3"
23
+ _RETRY_STATUS = {408, 425, 429, 500, 502, 503, 504}
24
+ FileInput = Union[str, "os.PathLike[str]", bytes, BinaryIO]
25
+
26
+
27
+ @dataclass
28
+ class HTTPResponse:
29
+ status: int
30
+ headers: Dict[str, str]
31
+ body: bytes = b""
32
+
33
+
34
+ @dataclass
35
+ class ConvertResult:
36
+ #: The finished job, with its tasks.
37
+ job: dict
38
+ #: The converted files, each with ``filename``, ``url`` (temporary) and ``size``.
39
+ files: List[dict]
40
+ #: Local paths, when ``out_dir`` was given.
41
+ saved: List[str] = field(default_factory=list)
42
+
43
+
44
+ class RunConvert:
45
+ """The RunConvert API client.
46
+
47
+ >>> rc = RunConvert() # reads RUNCONVERT_API_KEY
48
+ >>> rc.convert("report.docx", to="pdf", out_dir="out").saved
49
+ ['out/report.pdf']
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ api_key: Optional[str] = None,
55
+ *,
56
+ base_url: str = DEFAULT_BASE_URL,
57
+ timeout: float = 60.0,
58
+ max_retries: int = 2,
59
+ ) -> None:
60
+ self.api_key = api_key if api_key is not None else os.environ.get("RUNCONVERT_API_KEY", "")
61
+ self.base_url = base_url.rstrip("/")
62
+ self.timeout = timeout
63
+ self.max_retries = max_retries
64
+ self.user_agent = f"runconvert-python/{__version__} python/{platform.python_version()}"
65
+ self.jobs = Jobs(self)
66
+ self.operations = Operations(self)
67
+ self.files = Files(self)
68
+ self.webhooks = Webhooks()
69
+
70
+ # ---- high level ----
71
+
72
+ def me(self) -> dict:
73
+ """The account, team and credit balance behind the API key."""
74
+ return self.request("GET", "/me")
75
+
76
+ def convert(
77
+ self,
78
+ file: FileInput,
79
+ to: str,
80
+ *,
81
+ from_: Optional[str] = None,
82
+ options: Optional[Dict[str, Any]] = None,
83
+ out_dir: Optional[str] = None,
84
+ filename: Optional[str] = None,
85
+ tag: Optional[str] = None,
86
+ on_update: Optional[Callable[[dict], None]] = None,
87
+ timeout: Optional[float] = None,
88
+ ) -> ConvertResult:
89
+ """Converts one file in one call: upload (or import a URL), convert, wait, download.
90
+
91
+ ``file`` is a path, an ``http(s)://`` URL, bytes or a binary file object. ``from_`` is
92
+ read from the name when left out. Engine options (e.g. ``{"audioBitrate": "192k"}``)
93
+ go in ``options``.
94
+ """
95
+ is_url = isinstance(file, str) and file.lower().startswith(("http://", "https://"))
96
+ if filename:
97
+ name = filename
98
+ elif is_url:
99
+ name = urllib.parse.urlparse(str(file)).path
100
+ elif isinstance(file, (str, os.PathLike)):
101
+ name = os.fspath(file)
102
+ else:
103
+ name = getattr(file, "name", "") or ""
104
+ source = (from_ or os.path.splitext(str(name))[1].lstrip(".")).lower()
105
+ if not source:
106
+ raise ValueError('Can\'t tell the source format: pass from_="mp4" or filename="video.mp4".')
107
+ target = to.lower().lstrip(".")
108
+ import_task = {"operation": "import/url", "source_url": file} if is_url else {"operation": "import/direct/upload"}
109
+ convert_task = dict(options or {})
110
+ convert_task.update({"operation": "convert", "input": "import-1", "input_format": source, "output_format": target})
111
+ job = self.jobs.create(
112
+ {"import-1": import_task, "convert-1": convert_task, "export-1": {"operation": "export/url", "input": "convert-1"}},
113
+ tag=tag,
114
+ )
115
+ if not is_url:
116
+ task = next((t for t in job.get("tasks") or [] if t.get("name") == "import-1"), None)
117
+ if not task or not task.get("upload_url"):
118
+ raise RunConvertError("The job has no upload URL", body=job)
119
+ self.files.upload(task, file, filename=os.path.basename(str(name)) if name else None)
120
+ job = self.jobs.wait(job["id"], on_update=on_update, timeout=timeout)
121
+ files = Jobs.exported_files(job)
122
+ saved = [self.files.download(f, out_dir) for f in files] if out_dir else []
123
+ return ConvertResult(job=job, files=files, saved=saved)
124
+
125
+ # ---- HTTP ----
126
+
127
+ def request(self, method: str, path: str, body: Any = None, query: Optional[Dict[str, Any]] = None) -> Any:
128
+ """Sends a request and returns ``data`` from ``{"success": true, "data": …}``."""
129
+ return self.request_raw(method, path, body, query)[0]
130
+
131
+ def request_raw(self, method: str, path: str, body: Any = None, query: Optional[Dict[str, Any]] = None):
132
+ """Like :meth:`request`, returning ``(data, meta)``."""
133
+ params = {k: v for k, v in (query or {}).items() if v is not None and v != ""}
134
+ url = self.base_url + path + ("?" + urllib.parse.urlencode(params) if params else "")
135
+ headers = {"Accept": "application/json", "User-Agent": self.user_agent}
136
+ if self.api_key:
137
+ headers["Authorization"] = f"Bearer {self.api_key}"
138
+ data = None
139
+ if body is not None:
140
+ headers["Content-Type"] = "application/json"
141
+ data = json.dumps(body).encode("utf-8")
142
+ # POST /jobs is only repeated when it never reached the server, so a job is never created twice.
143
+ idempotent = method != "POST" or path.endswith("/cancel")
144
+ attempt = 0
145
+ while True:
146
+ try:
147
+ res = self._send(method, url, headers, data, self.timeout)
148
+ except (urllib.error.URLError, OSError) as err:
149
+ reached = isinstance(err, TimeoutError) or "timed out" in str(err)
150
+ if attempt < self.max_retries and (idempotent or not reached):
151
+ attempt += 1
152
+ time.sleep(_backoff(attempt))
153
+ continue
154
+ raise RunConvertError(f"Request to RunConvert failed: {err}") from err
155
+ if res.status in _RETRY_STATUS and attempt < self.max_retries and (idempotent or res.status == 429):
156
+ attempt += 1
157
+ time.sleep(_retry_after(res) or _backoff(attempt))
158
+ continue
159
+ if res.status == 204:
160
+ return None, {}
161
+ try:
162
+ payload = json.loads(res.body.decode("utf-8")) if res.body else None
163
+ except ValueError:
164
+ payload = None
165
+ if res.status >= 400 or (isinstance(payload, dict) and payload.get("success") is False):
166
+ err = (payload or {}).get("error") or {} if isinstance(payload, dict) else {}
167
+ raise RunConvertError(err.get("message") or f"RunConvert API error (HTTP {res.status})", res.status, err.get("code", ""), payload if payload is not None else res.body)
168
+ payload = payload or {}
169
+ return payload.get("data"), payload.get("meta") or {}
170
+
171
+ def _send(self, method: str, url: str, headers: Dict[str, str], data: Any, timeout: Optional[float]) -> HTTPResponse:
172
+ """One HTTP exchange. Overridden in tests."""
173
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
174
+ try:
175
+ with urllib.request.urlopen(req, timeout=timeout) as r:
176
+ return HTTPResponse(r.status, dict(r.headers), r.read())
177
+ except urllib.error.HTTPError as e:
178
+ return HTTPResponse(e.code, dict(e.headers or {}), e.read() or b"")
179
+
180
+ def _stream_to(self, url: str, dest: str) -> int:
181
+ """Downloads ``url`` to ``dest``; returns the status. Overridden in tests."""
182
+ req = urllib.request.Request(url, headers={"User-Agent": self.user_agent})
183
+ try:
184
+ with urllib.request.urlopen(req) as r, open(dest, "wb") as out:
185
+ shutil.copyfileobj(r, out, 1 << 20)
186
+ return r.status
187
+ except urllib.error.HTTPError as e:
188
+ return e.code
189
+
190
+
191
+ def _backoff(attempt: int) -> float:
192
+ return min(8.0, 0.5 * 2 ** (attempt - 1)) * (0.75 + random.random() / 2)
193
+
194
+
195
+ def _retry_after(res: HTTPResponse) -> Optional[float]:
196
+ value = {k.lower(): v for k, v in res.headers.items()}.get("retry-after")
197
+ try:
198
+ seconds = float(value) if value else 0
199
+ except ValueError:
200
+ return None
201
+ return min(seconds, 30.0) if seconds > 0 else None
202
+
203
+
204
+ class Jobs:
205
+ """Create, read, wait for and stop jobs."""
206
+
207
+ def __init__(self, client: RunConvert) -> None:
208
+ self._c = client
209
+
210
+ def create(self, tasks: Dict[str, dict], *, tag: Optional[str] = None, metadata: Optional[dict] = None) -> dict:
211
+ """Creates a job from named tasks. Returned with its tasks (an upload task carries ``upload_url``)."""
212
+ body: Dict[str, Any] = {"tasks": tasks}
213
+ if tag:
214
+ body["tag"] = tag
215
+ if metadata:
216
+ body["metadata"] = metadata
217
+ return self._c.request("POST", "/jobs", body, {"include": "tasks"})
218
+
219
+ def get(self, job_id: str) -> dict:
220
+ """A job with its tasks."""
221
+ return self._c.request("GET", f"/jobs/{urllib.parse.quote(job_id, safe='')}", query={"include": "tasks"})
222
+
223
+ def list(self, *, status: Optional[str] = None, tag: Optional[str] = None, page: Optional[int] = None,
224
+ per_page: Optional[int] = None, include_tasks: bool = False) -> Dict[str, Any]:
225
+ """One page of jobs, newest first: ``{"data": [...], "meta": {...}}``."""
226
+ data, meta = self._c.request_raw("GET", "/jobs", query={
227
+ "filter[status]": status, "filter[tag]": tag, "page": page, "per_page": per_page,
228
+ "include": "tasks" if include_tasks else None,
229
+ })
230
+ return {"data": data or [], "meta": meta}
231
+
232
+ def all(self, **filters: Any) -> Iterator[dict]:
233
+ """Every job, page by page: ``for job in rc.jobs.all(status="finished")``."""
234
+ page = 1
235
+ while True:
236
+ result = self.list(page=page, **filters)
237
+ yield from result["data"]
238
+ total_pages = result["meta"].get("total_pages")
239
+ if not result["data"] or (total_pages is not None and page >= total_pages):
240
+ return
241
+ page += 1
242
+
243
+ def cancel(self, job_id: str) -> dict:
244
+ """Cancels pending tasks; credits for unfinished tasks are refunded."""
245
+ return self._c.request("POST", f"/jobs/{urllib.parse.quote(job_id, safe='')}/cancel")
246
+
247
+ def delete(self, job_id: str) -> None:
248
+ """Deletes a job."""
249
+ self._c.request("DELETE", f"/jobs/{urllib.parse.quote(job_id, safe='')}")
250
+
251
+ def wait(self, job_id: str, *, timeout: Optional[float] = None, on_update: Optional[Callable[[dict], None]] = None,
252
+ raise_on_error: bool = True) -> dict:
253
+ """Polls until ``finished`` or ``error`` (0.7 s, slowing to 3 s). Raises JobFailedError for a failed job."""
254
+ started = time.monotonic()
255
+ delay = 0.7
256
+ while True:
257
+ job = self.get(job_id)
258
+ if on_update:
259
+ on_update(job)
260
+ if job.get("status") == "finished":
261
+ return job
262
+ if job.get("status") == "error":
263
+ if not raise_on_error:
264
+ return job
265
+ failed = next((t for t in job.get("tasks") or [] if t.get("status") == "error"), None)
266
+ raise JobFailedError(job, failed)
267
+ if timeout is not None and time.monotonic() - started + delay > timeout:
268
+ raise WaitTimeoutError(job, timeout)
269
+ time.sleep(delay)
270
+ delay = min(delay + 0.3, 3.0)
271
+
272
+ @staticmethod
273
+ def exported_files(job: dict) -> List[dict]:
274
+ """The files produced by the job's ``export/url`` tasks."""
275
+ return [f for t in job.get("tasks") or [] if t.get("operation") == "export/url" for f in (t.get("result") or {}).get("files") or []]
276
+
277
+
278
+ class Operations:
279
+ """Supported formats, targets, engines and options. Public: no key needed."""
280
+
281
+ def __init__(self, client: RunConvert) -> None:
282
+ self._c = client
283
+
284
+ def list(self, *, category: Optional[str] = None) -> dict:
285
+ """Every input format with its targets: ``{"categories": [...], "formats": [...]}``."""
286
+ return self._c.request("GET", "/operations", query={"category": category})
287
+
288
+ def for_input(self, input_format: str) -> dict:
289
+ """What one format converts to, with credit costs."""
290
+ return self._c.request("GET", "/operations", query={"input": input_format.lower()})
291
+
292
+ def for_pair(self, input_format: str, output_format: str) -> dict:
293
+ """Engines for one conversion and the options a convert task accepts."""
294
+ return self._c.request("GET", "/operations", query={"input": input_format.lower(), "output": output_format.lower()})
295
+
296
+
297
+ class Files:
298
+ """Uploads to ``import/direct/upload`` tasks and downloads of results."""
299
+
300
+ def __init__(self, client: RunConvert) -> None:
301
+ self._c = client
302
+
303
+ def upload(self, task: Union[dict, str], file: FileInput, *, filename: Optional[str] = None) -> None:
304
+ """PUTs a path, bytes or binary file object to the task's ``upload_url`` before its deadline."""
305
+ upload_url = task if isinstance(task, str) else task.get("upload_url")
306
+ if not upload_url:
307
+ raise ValueError("This task has no upload_url (is it an import/direct/upload task?)")
308
+ if filename is None and isinstance(file, (str, os.PathLike)):
309
+ filename = os.path.basename(os.fspath(file))
310
+ url = _with_query(upload_url, {"filename": filename} if filename else {})
311
+ headers = {"Content-Type": "application/octet-stream", "User-Agent": self._c.user_agent}
312
+ if isinstance(file, (str, os.PathLike)):
313
+ headers["Content-Length"] = str(os.path.getsize(file))
314
+ with open(file, "rb") as fh:
315
+ res = self._c._send("PUT", url, headers, fh, None)
316
+ else:
317
+ res = self._c._send("PUT", url, headers, file, None)
318
+ if res.status >= 300:
319
+ raise RunConvertError(f"Upload failed (HTTP {res.status}): {res.body[:300].decode('utf-8', 'replace')}", res.status)
320
+
321
+ def download(self, file: Union[dict, str], out_dir: str, filename: Optional[str] = None) -> str:
322
+ """Saves a result file (or a URL) into ``out_dir`` keeping its name; returns the path."""
323
+ url = file if isinstance(file, str) else file["url"]
324
+ name = filename or (os.path.basename(urllib.parse.urlparse(url).path) if isinstance(file, str) else file["filename"])
325
+ os.makedirs(out_dir, exist_ok=True)
326
+ dest = os.path.join(out_dir, os.path.basename(name))
327
+ status = self._c._stream_to(url, dest)
328
+ if status >= 300:
329
+ raise RunConvertError(f"Download failed (HTTP {status})", status)
330
+ return dest
331
+
332
+
333
+ class Webhooks:
334
+ """``verify`` and ``construct_event`` for webhook deliveries."""
335
+
336
+ verify = staticmethod(_webhooks.verify_signature)
337
+ construct_event = staticmethod(_webhooks.construct_event)
338
+
339
+
340
+ def _with_query(url: str, extra: Dict[str, str]) -> str:
341
+ parts = urllib.parse.urlsplit(url)
342
+ query = dict(urllib.parse.parse_qsl(parts.query, keep_blank_values=True))
343
+ query.update(extra)
344
+ return urllib.parse.urlunsplit(parts._replace(query=urllib.parse.urlencode(query)))
@@ -0,0 +1,41 @@
1
+ """Errors raised by the RunConvert client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional
6
+
7
+
8
+ class RunConvertError(Exception):
9
+ """The API said no: ``{"success": false, "error": {"code", "message"}}``.
10
+
11
+ ``code`` is machine-readable (e.g. ``BILLING_INSUFFICIENT_CREDITS``). New codes can
12
+ appear, so branch on ``status`` for anything you don't recognise.
13
+ """
14
+
15
+ def __init__(self, message: str, status: int = 0, code: str = "", body: Any = None) -> None:
16
+ super().__init__(message)
17
+ self.message = message
18
+ #: HTTP status, or 0 when the request never got a response.
19
+ self.status = status
20
+ #: API error code, empty when the API sent none.
21
+ self.code = code
22
+ #: The raw response body, when there was one.
23
+ self.body = body
24
+
25
+
26
+ class JobFailedError(RunConvertError):
27
+ """The job ended with status ``error``. ``task`` is the first failed task (code, reason, message)."""
28
+
29
+ def __init__(self, job: dict, task: Optional[dict] = None) -> None:
30
+ message = (task or {}).get("message") or f"Job {job.get('id')} failed"
31
+ super().__init__(message, 0, (task or {}).get("code", ""), job)
32
+ self.job = job
33
+ self.task = task
34
+
35
+
36
+ class WaitTimeoutError(RunConvertError):
37
+ """``jobs.wait`` gave up before the job reached ``finished`` or ``error``."""
38
+
39
+ def __init__(self, job: dict, timeout: float) -> None:
40
+ super().__init__(f"Job {job.get('id')} still {job.get('status')} after {round(timeout)}s", 0, "WAIT_TIMEOUT", job)
41
+ self.job = job
File without changes
@@ -0,0 +1,37 @@
1
+ """Webhook signature checks.
2
+
3
+ Every delivery has an ``X-RunConvert-Signature`` header: ``sha256=`` followed by the hex
4
+ HMAC-SHA256 of the **raw** request body, keyed with the endpoint's secret.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import hmac
11
+ import json
12
+ import re
13
+ from typing import Optional, Union
14
+
15
+ _HEX64 = re.compile(r"^[0-9a-f]{64}$")
16
+
17
+
18
+ def verify_signature(raw_body: Union[str, bytes], signature: Optional[str], secret: str) -> bool:
19
+ """True when ``signature`` matches the body. Pass the body exactly as received."""
20
+ if not signature or not secret:
21
+ return False
22
+ given = signature.strip()
23
+ if given.lower().startswith("sha256="):
24
+ given = given[7:]
25
+ given = given.lower()
26
+ if not _HEX64.match(given):
27
+ return False
28
+ body = raw_body.encode("utf-8") if isinstance(raw_body, str) else raw_body
29
+ expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
30
+ return hmac.compare_digest(expected, given)
31
+
32
+
33
+ def construct_event(raw_body: Union[str, bytes], signature: Optional[str], secret: str) -> dict:
34
+ """Verifies the signature, then parses the event. Raises ``ValueError`` if it doesn't match."""
35
+ if not verify_signature(raw_body, signature, secret):
36
+ raise ValueError("Invalid RunConvert webhook signature")
37
+ return json.loads(raw_body)
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.4
2
+ Name: runconvert
3
+ Version: 0.1.0
4
+ Summary: Official RunConvert SDK for Python: convert files between 240+ formats
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://www.runconvert.com
7
+ Project-URL: Documentation, https://www.runconvert.com/docs
8
+ Project-URL: Source, https://github.com/runconvert/runconvert-python
9
+ Keywords: runconvert,file converter,convert,pdf,video,image,api,sdk
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Typing :: Typed
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Dynamic: license-file
17
+
18
+ # RunConvert SDK for Python
19
+
20
+ Convert files between 240+ formats (3,000+ conversions) with the [RunConvert API](https://www.runconvert.com/docs):
21
+ documents, images, video, audio, ebooks, archives, fonts and CAD. Python 3.10+, no dependencies.
22
+
23
+ ```bash
24
+ pip install runconvert
25
+ ```
26
+
27
+ ```python
28
+ from runconvert import RunConvert
29
+
30
+ rc = RunConvert() # reads RUNCONVERT_API_KEY
31
+
32
+ # One call: upload, convert, wait, download.
33
+ result = rc.convert("report.docx", "pdf", out_dir="out")
34
+ print(result.saved) # ['out/report.pdf']
35
+ ```
36
+
37
+ Get an API key in **Dashboard → API Keys** on [runconvert.com](https://www.runconvert.com). Keep keys on the server.
38
+
39
+ ## Convert
40
+
41
+ ```python
42
+ # From a URL (imported by RunConvert, nothing to upload)
43
+ rc.convert("https://example.com/talk.mp4", "mp3", options={"audioBitrate": "192k"})
44
+
45
+ # From bytes or a file object: give a filename (or from_)
46
+ rc.convert(data, "jpg", filename="photo.heic", out_dir="out")
47
+
48
+ # Progress and a time limit (seconds)
49
+ rc.convert("movie.mov", "mp4", out_dir="out", on_update=lambda job: print(job["status"]), timeout=600)
50
+ ```
51
+
52
+ `convert` returns `ConvertResult(job, files, saved)`. Options for a conversion come from
53
+ `rc.operations.for_pair("mp4", "mp3")`.
54
+
55
+ ## Jobs with your own tasks
56
+
57
+ ```python
58
+ from runconvert import Jobs
59
+
60
+ # Merge three PDFs into one
61
+ job = rc.jobs.create({
62
+ "a": {"operation": "import/url", "source_url": "https://example.com/cover.pdf"},
63
+ "b": {"operation": "import/url", "source_url": "https://example.com/body.pdf"},
64
+ "merge": {"operation": "convert", "input": ["a", "b"], "input_format": "pdf", "output_format": "pdf"},
65
+ "export": {"operation": "export/url", "input": "merge"},
66
+ }, tag="merge")
67
+ done = rc.jobs.wait(job["id"])
68
+ for f in Jobs.exported_files(done):
69
+ rc.files.download(f, "out")
70
+
71
+ # Upload your own file
72
+ job = rc.jobs.create({
73
+ "upload": {"operation": "import/direct/upload"},
74
+ "convert": {"operation": "convert", "input": "upload", "input_format": "png", "output_format": "webp", "image_quality": 80},
75
+ "export": {"operation": "export/url", "input": "convert"},
76
+ })
77
+ upload_task = next(t for t in job["tasks"] if t["name"] == "upload")
78
+ rc.files.upload(upload_task, "image.png")
79
+ ```
80
+
81
+ | Method | What it does |
82
+ |---|---|
83
+ | `rc.jobs.create(tasks, tag=, metadata=)` | Create a job (returned with its tasks) |
84
+ | `rc.jobs.get(id)` / `rc.jobs.list(status=, tag=, page=, per_page=)` | A job / one page `{"data", "meta"}` |
85
+ | `rc.jobs.all(...)` | Every job: `for job in rc.jobs.all()` |
86
+ | `rc.jobs.wait(id, timeout=, on_update=, raise_on_error=)` | Poll until `finished` or `error` |
87
+ | `rc.jobs.cancel(id)` / `rc.jobs.delete(id)` | Stop pending tasks / delete |
88
+ | `rc.files.upload(task, file)` / `rc.files.download(file, dir)` | Upload to a task / save a result |
89
+ | `rc.operations.list(category=)` / `for_input("mp4")` / `for_pair("mp4", "mp3")` | Formats, targets, engines (no key needed) |
90
+ | `rc.me()` | Account, team and credit balance |
91
+
92
+ ## Webhooks
93
+
94
+ ```python
95
+ from runconvert import construct_event
96
+
97
+ @app.post("/webhooks/runconvert") # Flask
98
+ def runconvert_webhook():
99
+ try:
100
+ event = construct_event(request.get_data(), request.headers.get("X-RunConvert-Signature"), os.environ["RUNCONVERT_WEBHOOK_SECRET"])
101
+ except ValueError:
102
+ return "", 400
103
+ if event["event"] == "job.finished":
104
+ print("done", event["job"]["id"])
105
+ return "", 200
106
+ ```
107
+
108
+ ## Errors
109
+
110
+ | Error | When |
111
+ |---|---|
112
+ | `RunConvertError` | The API said no: `.status`, `.code` (e.g. `BILLING_INSUFFICIENT_CREDITS`), `.message` |
113
+ | `JobFailedError` | The job ended in `error`; `.task` has `code`, `reason`, `message` |
114
+ | `WaitTimeoutError` | `wait` ran past its `timeout` |
115
+
116
+ Network errors, `429` and `5xx` are retried twice with backoff (`max_retries`). Creating a job is only
117
+ retried when the request never reached the API, so a job is never created twice.
118
+
119
+ ## Development
120
+
121
+ ```bash
122
+ PYTHONPATH=src python3 -m unittest tests.test_unit # no network
123
+ RUNCONVERT_API_KEY=rc_live_… PYTHONPATH=src python3 -m unittest tests.test_live # real API, about 2 credits
124
+ ```
125
+
126
+ ## License
127
+
128
+ MIT
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/runconvert/__init__.py
5
+ src/runconvert/client.py
6
+ src/runconvert/errors.py
7
+ src/runconvert/py.typed
8
+ src/runconvert/webhooks.py
9
+ src/runconvert.egg-info/PKG-INFO
10
+ src/runconvert.egg-info/SOURCES.txt
11
+ src/runconvert.egg-info/dependency_links.txt
12
+ src/runconvert.egg-info/top_level.txt
13
+ tests/test_live.py
14
+ tests/test_unit.py
@@ -0,0 +1 @@
1
+ runconvert
@@ -0,0 +1,44 @@
1
+ """Live tests against the real API. Needs RUNCONVERT_API_KEY; converts one small text file (about 2 credits)."""
2
+ import os
3
+ import tempfile
4
+ import unittest
5
+
6
+ from runconvert import RunConvert, RunConvertError
7
+
8
+ KEY = os.environ.get("RUNCONVERT_API_KEY")
9
+
10
+
11
+ @unittest.skipUnless(KEY, "set RUNCONVERT_API_KEY to run live tests")
12
+ class LiveTests(unittest.TestCase):
13
+ def test_me(self):
14
+ me = RunConvert().me()
15
+ self.assertTrue(me.get("credits"))
16
+
17
+ def test_operations(self):
18
+ rc = RunConvert(api_key="")
19
+ self.assertTrue(any(f["format"] == "txt" for f in rc.operations.list(category="document")["formats"]))
20
+ self.assertTrue(any(o["format"] == "pdf" for o in rc.operations.for_input("TXT")["outputs"]))
21
+ self.assertTrue(rc.operations.for_pair("mp4", "mp3")["engines"])
22
+
23
+ def test_convert_text_to_pdf(self):
24
+ rc = RunConvert()
25
+ with tempfile.TemporaryDirectory() as d:
26
+ src = os.path.join(d, "sdk-live-test.txt")
27
+ with open(src, "w", encoding="utf-8") as f:
28
+ f.write("RunConvert Python SDK live test\nLine two\n\nCafé – 你好 – Привет\n")
29
+ seen = []
30
+ res = rc.convert(src, "pdf", out_dir=os.path.join(d, "out"), tag="sdk-python-live-test", on_update=lambda j: seen.append(j["status"]), timeout=150)
31
+ self.assertEqual(res.job["status"], "finished")
32
+ with open(res.saved[0], "rb") as f:
33
+ pdf = f.read()
34
+ self.assertEqual(pdf[:5], b"%PDF-")
35
+ print(f"\n job {res.job['id']}: {' → '.join(dict.fromkeys(seen))}, {res.job.get('credits_used')} credit(s), {len(pdf)} bytes")
36
+
37
+ def test_bad_key(self):
38
+ with self.assertRaises(RunConvertError) as cm:
39
+ RunConvert(api_key="rc_live_invalid", max_retries=0).me()
40
+ self.assertEqual(cm.exception.status, 401)
41
+
42
+
43
+ if __name__ == "__main__":
44
+ unittest.main()
@@ -0,0 +1,165 @@
1
+ import hashlib
2
+ import hmac
3
+ import json
4
+ import os
5
+ import tempfile
6
+ import unittest
7
+ import urllib.parse
8
+
9
+ from runconvert import JobFailedError, RunConvert, RunConvertError, WaitTimeoutError, construct_event, verify_signature
10
+ from runconvert.client import HTTPResponse
11
+
12
+
13
+ def ok(data, meta=None):
14
+ body = {"success": True, "data": data}
15
+ if meta:
16
+ body["meta"] = meta
17
+ return HTTPResponse(200, {}, json.dumps(body).encode())
18
+
19
+
20
+ def fail(status, code, message, headers=None):
21
+ return HTTPResponse(status, headers or {}, json.dumps({"success": False, "error": {"code": code, "message": message}}).encode())
22
+
23
+
24
+ class Fake(RunConvert):
25
+ """A client whose HTTP goes to ``handler(call, n)`` instead of the network."""
26
+
27
+ def __init__(self, handler, **kw):
28
+ super().__init__("rc_live_test", base_url="https://api.example/v3", max_retries=2, **kw)
29
+ self.handler = handler
30
+ self.calls = []
31
+ self.downloads = {}
32
+
33
+ def _send(self, method, url, headers, data, timeout):
34
+ u = urllib.parse.urlsplit(url)
35
+ body = data.read() if hasattr(data, "read") else data
36
+ call = {"method": method, "path": u.path.replace("/v3", "", 1), "query": dict(urllib.parse.parse_qsl(u.query)), "headers": headers, "body": body}
37
+ self.calls.append(call)
38
+ return self.handler(call, len(self.calls))
39
+
40
+ def _stream_to(self, url, dest):
41
+ with open(dest, "wb") as f:
42
+ f.write(self.downloads.get(url, b""))
43
+ return 200 if url in self.downloads else 404
44
+
45
+
46
+ class ClientTests(unittest.TestCase):
47
+ def setUp(self):
48
+ import runconvert.client as c
49
+ self._sleep = c.time.sleep
50
+ c.time.sleep = lambda s: None # no real waiting in tests
51
+
52
+ def tearDown(self):
53
+ import runconvert.client as c
54
+ c.time.sleep = self._sleep
55
+
56
+ def test_key_and_envelope(self):
57
+ rc = Fake(lambda call, n: ok({"credits": {"total_remaining": 42}}))
58
+ self.assertEqual(rc.me()["credits"]["total_remaining"], 42)
59
+ self.assertEqual(rc.calls[0]["headers"]["Authorization"], "Bearer rc_live_test")
60
+ self.assertTrue(rc.calls[0]["headers"]["User-Agent"].startswith("runconvert-python/"))
61
+
62
+ def test_api_error(self):
63
+ rc = Fake(lambda call, n: fail(422, "BILLING_INSUFFICIENT_CREDITS", "Insufficient credits."))
64
+ with self.assertRaises(RunConvertError) as cm:
65
+ rc.jobs.get("job_1")
66
+ self.assertEqual((cm.exception.status, cm.exception.code, str(cm.exception)), (422, "BILLING_INSUFFICIENT_CREDITS", "Insufficient credits."))
67
+
68
+ def test_get_retries_503(self):
69
+ rc = Fake(lambda call, n: HTTPResponse(503, {}, b"busy") if n < 3 else ok({"id": "job_1", "status": "finished"}))
70
+ self.assertEqual(rc.jobs.get("job_1")["status"], "finished")
71
+ self.assertEqual(len(rc.calls), 3)
72
+
73
+ def test_create_not_retried_after_500(self):
74
+ rc = Fake(lambda call, n: fail(500, "INTERNAL", "boom"))
75
+ with self.assertRaises(RunConvertError):
76
+ rc.jobs.create({})
77
+ self.assertEqual(len(rc.calls), 1)
78
+
79
+ def test_create_retried_on_429(self):
80
+ rc = Fake(lambda call, n: fail(429, "ACTIVE_JOB_LIMIT_REACHED", "x", {"Retry-After": "0"}) if n == 1 else ok({"id": "job_1", "status": "waiting", "tasks": []}))
81
+ self.assertEqual(rc.jobs.create({})["id"], "job_1")
82
+ self.assertEqual(len(rc.calls), 2)
83
+ self.assertEqual(rc.calls[1]["query"]["include"], "tasks")
84
+
85
+ def test_list_and_all(self):
86
+ rc = Fake(lambda call, n: ok([{"id": "job_p" + call["query"]["page"], "status": "finished"}], {"page": int(call["query"]["page"]), "total_pages": 2}))
87
+ page = rc.jobs.list(status="finished", tag="x", page=1, per_page=1)
88
+ self.assertEqual(page["meta"]["total_pages"], 2)
89
+ self.assertEqual(rc.calls[0]["query"]["filter[status]"], "finished")
90
+ self.assertEqual([j["id"] for j in rc.jobs.all(per_page=1)], ["job_p1", "job_p2"])
91
+
92
+ def test_wait(self):
93
+ states = ["waiting", "processing", "finished"]
94
+ rc = Fake(lambda call, n: ok({"id": "job_1", "status": states[min(n - 1, 2)], "tasks": []}))
95
+ seen = []
96
+ self.assertEqual(rc.jobs.wait("job_1", on_update=lambda j: seen.append(j["status"]))["status"], "finished")
97
+ self.assertEqual(seen, states)
98
+
99
+ def test_wait_failed_and_timeout(self):
100
+ failed = {"id": "job_1", "status": "error", "tasks": [{"name": "c", "operation": "convert", "status": "error", "code": "INVALID_DATA", "reason": "INPUT_EMPTY", "message": "The file is empty."}]}
101
+ rc = Fake(lambda call, n: ok(failed))
102
+ with self.assertRaises(JobFailedError) as cm:
103
+ rc.jobs.wait("job_1")
104
+ self.assertEqual((cm.exception.task["reason"], str(cm.exception)), ("INPUT_EMPTY", "The file is empty."))
105
+ self.assertEqual(rc.jobs.wait("job_1", raise_on_error=False)["status"], "error")
106
+ rc2 = Fake(lambda call, n: ok({"id": "job_1", "status": "processing", "tasks": []}))
107
+ with self.assertRaises(WaitTimeoutError):
108
+ rc2.jobs.wait("job_1", timeout=0.001)
109
+
110
+ def test_delete_204_and_operations(self):
111
+ rc = Fake(lambda call, n: HTTPResponse(204, {}) if call["method"] == "DELETE" else ok({"engines": []}))
112
+ self.assertIsNone(rc.jobs.delete("job_1"))
113
+ rc.operations.for_pair("MP4", "MP3")
114
+ self.assertEqual(rc.calls[1]["query"], {"input": "mp4", "output": "mp3"})
115
+
116
+ def test_webhooks(self):
117
+ body = json.dumps({"event": "job.finished", "job": {"id": "job_1"}})
118
+ sig = "sha256=" + hmac.new(b"whsec_x", body.encode(), hashlib.sha256).hexdigest()
119
+ self.assertTrue(verify_signature(body, sig, "whsec_x"))
120
+ self.assertTrue(verify_signature(body.encode(), sig, "whsec_x"))
121
+ self.assertFalse(verify_signature(body + " ", sig, "whsec_x"))
122
+ self.assertFalse(verify_signature(body, sig, "other"))
123
+ self.assertFalse(verify_signature(body, "sha256=nothex", "whsec_x"))
124
+ self.assertFalse(verify_signature(body, None, "whsec_x"))
125
+ self.assertEqual(construct_event(body, sig, "whsec_x")["job"]["id"], "job_1")
126
+ with self.assertRaises(ValueError):
127
+ construct_event(body, "sha256=" + "0" * 64, "whsec_x")
128
+
129
+ def test_convert_upload_wait_download(self):
130
+ polls = {"n": 0}
131
+
132
+ def handler(call, n):
133
+ if call["method"] == "POST" and call["path"] == "/jobs":
134
+ return ok({"id": "job_1", "status": "waiting", "tasks": [{"name": "import-1", "operation": "import/direct/upload", "status": "waiting", "upload_url": "https://up.example/u/1?token=t"}]})
135
+ if call["method"] == "PUT":
136
+ return HTTPResponse(200, {}, b"")
137
+ polls["n"] += 1
138
+ return ok({"id": "job_1", "status": "finished" if polls["n"] > 1 else "processing", "tasks": [{"name": "export-1", "operation": "export/url", "status": "finished", "result": {"files": [{"filename": "notes.pdf", "url": "https://dl.example/notes.pdf"}]}}]})
139
+
140
+ rc = Fake(handler)
141
+ rc.downloads["https://dl.example/notes.pdf"] = b"PDF"
142
+ with tempfile.TemporaryDirectory() as d:
143
+ src = os.path.join(d, "notes.txt")
144
+ with open(src, "w") as f:
145
+ f.write("hello")
146
+ res = rc.convert(src, "pdf", out_dir=os.path.join(d, "out"), options={"page_size": "a4"})
147
+ created = json.loads(rc.calls[0]["body"])
148
+ self.assertEqual(created["tasks"]["convert-1"], {"page_size": "a4", "operation": "convert", "input": "import-1", "input_format": "txt", "output_format": "pdf"})
149
+ put = next(c for c in rc.calls if c["method"] == "PUT")
150
+ self.assertEqual((put["query"]["filename"], put["query"]["token"], put["body"]), ("notes.txt", "t", b"hello"))
151
+ with open(res.saved[0], "rb") as f:
152
+ self.assertEqual(f.read(), b"PDF")
153
+
154
+ def test_convert_url_and_bytes(self):
155
+ rc = Fake(lambda call, n: ok({"id": "job_2", "status": "finished" if call["method"] == "GET" else "waiting", "tasks": []}))
156
+ rc.convert("https://example.com/a/talk.MP4?x=1", "mp3")
157
+ tasks = json.loads(rc.calls[0]["body"])["tasks"]
158
+ self.assertEqual(tasks["import-1"], {"operation": "import/url", "source_url": "https://example.com/a/talk.MP4?x=1"})
159
+ self.assertEqual(tasks["convert-1"]["input_format"], "mp4")
160
+ with self.assertRaises(ValueError):
161
+ rc.convert(b"x", "pdf")
162
+
163
+
164
+ if __name__ == "__main__":
165
+ unittest.main()