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.
- runconvert-0.1.0/LICENSE +21 -0
- runconvert-0.1.0/PKG-INFO +128 -0
- runconvert-0.1.0/README.md +111 -0
- runconvert-0.1.0/pyproject.toml +30 -0
- runconvert-0.1.0/setup.cfg +4 -0
- runconvert-0.1.0/src/runconvert/__init__.py +19 -0
- runconvert-0.1.0/src/runconvert/client.py +344 -0
- runconvert-0.1.0/src/runconvert/errors.py +41 -0
- runconvert-0.1.0/src/runconvert/py.typed +0 -0
- runconvert-0.1.0/src/runconvert/webhooks.py +37 -0
- runconvert-0.1.0/src/runconvert.egg-info/PKG-INFO +128 -0
- runconvert-0.1.0/src/runconvert.egg-info/SOURCES.txt +14 -0
- runconvert-0.1.0/src/runconvert.egg-info/dependency_links.txt +1 -0
- runconvert-0.1.0/src/runconvert.egg-info/top_level.txt +1 -0
- runconvert-0.1.0/tests/test_live.py +44 -0
- runconvert-0.1.0/tests/test_unit.py +165 -0
runconvert-0.1.0/LICENSE
ADDED
|
@@ -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,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
|
+
|
|
@@ -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()
|