uvpdftool 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.
- uvpdftool-0.1.0/LICENSE +21 -0
- uvpdftool-0.1.0/PKG-INFO +69 -0
- uvpdftool-0.1.0/README.md +51 -0
- uvpdftool-0.1.0/pyproject.toml +28 -0
- uvpdftool-0.1.0/setup.cfg +4 -0
- uvpdftool-0.1.0/tests/test_client.py +262 -0
- uvpdftool-0.1.0/uvpdftool/__init__.py +26 -0
- uvpdftool-0.1.0/uvpdftool/client.py +360 -0
- uvpdftool-0.1.0/uvpdftool.egg-info/PKG-INFO +69 -0
- uvpdftool-0.1.0/uvpdftool.egg-info/SOURCES.txt +10 -0
- uvpdftool-0.1.0/uvpdftool.egg-info/dependency_links.txt +1 -0
- uvpdftool-0.1.0/uvpdftool.egg-info/top_level.txt +1 -0
uvpdftool-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 UV ResearchLabs
|
|
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.
|
uvpdftool-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: uvpdftool
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The UV pdfTool API from Python: compress, convert, OCR and check PDFs on UV pdfTool's servers.
|
|
5
|
+
Author: UV ResearchLabs
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://uvpdftool.com/developers
|
|
8
|
+
Project-URL: Documentation, https://uvpdftool.com/developers/sdks
|
|
9
|
+
Keywords: pdf,compress,ocr,convert,pdfa,api,uvpdftool
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# uvpdftool
|
|
20
|
+
|
|
21
|
+
The [UV pdfTool API](https://uvpdftool.com/developers) from Python 3.9 and later: compress,
|
|
22
|
+
convert, OCR and check PDFs on UV pdfTool's servers. The standard library only; type hints
|
|
23
|
+
included.
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
pip install uvpdftool
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import os
|
|
31
|
+
|
|
32
|
+
from uvpdftool import Client, JobFailedError
|
|
33
|
+
|
|
34
|
+
client = Client(api_key=os.environ["UVPDF_KEY"])
|
|
35
|
+
|
|
36
|
+
# Creates the job, uploads the file, starts it and waits for it.
|
|
37
|
+
job = client.run(
|
|
38
|
+
"compress",
|
|
39
|
+
files=["report.pdf"],
|
|
40
|
+
options={"method": "ghostscript", "preset": "ebook"},
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
for output in job.outputs:
|
|
44
|
+
client.download(output, output.name)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Make a key on the [API keys page](https://uvpdftool.com/developers/keys); a test key is free
|
|
48
|
+
and marks its results. Keep the key on your server, never in a browser or an app.
|
|
49
|
+
|
|
50
|
+
## What it does
|
|
51
|
+
|
|
52
|
+
| Call | Does |
|
|
53
|
+
| --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
|
54
|
+
| `Client(api_key, base_url=None, timeout=60)` | The client. |
|
|
55
|
+
| `client.run(tool, files, options=None, retention_hours=None, timeout=None, on_progress=None)` | Create, upload, start, wait; returns the finished job or raises `JobFailedError`. |
|
|
56
|
+
| `client.download(output, path=None)` | The bytes of one output, or written to `path`. |
|
|
57
|
+
| `client.create_job(tool, files, options=None, retention_hours=None)` | `POST /jobs`; `.job` and `.uploads`. |
|
|
58
|
+
| `client.upload(upload, data)` | `PUT` the bytes (or a path's) to one link. |
|
|
59
|
+
| `client.start_job(id)`, `client.get_job(id)`, `client.cancel_job(id)` | The three job calls. |
|
|
60
|
+
| `client.wait(id, interval=2.0, timeout=None, on_progress=None)` | Polls until the job ends. |
|
|
61
|
+
| `client.tools()`, `client.account()` | `GET /tools`, `GET /account`. |
|
|
62
|
+
| `verify_webhook(raw_body, signature_header, secret, tolerance=300)` | True when a webhook delivery is genuine and fresh. |
|
|
63
|
+
|
|
64
|
+
A file is a path, or `(name, bytes, content_type)`, or `(name, file_object, content_type, size)`.
|
|
65
|
+
A refused request raises `ApiError` (`status`, `code`, `message`, `details`, `retry_after`);
|
|
66
|
+
a job that fails or is cancelled raises `JobFailedError` (`.job`, with `job.error`).
|
|
67
|
+
|
|
68
|
+
The tools, their options and the errors are documented at
|
|
69
|
+
<https://uvpdftool.com/developers>.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# uvpdftool
|
|
2
|
+
|
|
3
|
+
The [UV pdfTool API](https://uvpdftool.com/developers) from Python 3.9 and later: compress,
|
|
4
|
+
convert, OCR and check PDFs on UV pdfTool's servers. The standard library only; type hints
|
|
5
|
+
included.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pip install uvpdftool
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
```python
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
from uvpdftool import Client, JobFailedError
|
|
15
|
+
|
|
16
|
+
client = Client(api_key=os.environ["UVPDF_KEY"])
|
|
17
|
+
|
|
18
|
+
# Creates the job, uploads the file, starts it and waits for it.
|
|
19
|
+
job = client.run(
|
|
20
|
+
"compress",
|
|
21
|
+
files=["report.pdf"],
|
|
22
|
+
options={"method": "ghostscript", "preset": "ebook"},
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
for output in job.outputs:
|
|
26
|
+
client.download(output, output.name)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Make a key on the [API keys page](https://uvpdftool.com/developers/keys); a test key is free
|
|
30
|
+
and marks its results. Keep the key on your server, never in a browser or an app.
|
|
31
|
+
|
|
32
|
+
## What it does
|
|
33
|
+
|
|
34
|
+
| Call | Does |
|
|
35
|
+
| --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
|
36
|
+
| `Client(api_key, base_url=None, timeout=60)` | The client. |
|
|
37
|
+
| `client.run(tool, files, options=None, retention_hours=None, timeout=None, on_progress=None)` | Create, upload, start, wait; returns the finished job or raises `JobFailedError`. |
|
|
38
|
+
| `client.download(output, path=None)` | The bytes of one output, or written to `path`. |
|
|
39
|
+
| `client.create_job(tool, files, options=None, retention_hours=None)` | `POST /jobs`; `.job` and `.uploads`. |
|
|
40
|
+
| `client.upload(upload, data)` | `PUT` the bytes (or a path's) to one link. |
|
|
41
|
+
| `client.start_job(id)`, `client.get_job(id)`, `client.cancel_job(id)` | The three job calls. |
|
|
42
|
+
| `client.wait(id, interval=2.0, timeout=None, on_progress=None)` | Polls until the job ends. |
|
|
43
|
+
| `client.tools()`, `client.account()` | `GET /tools`, `GET /account`. |
|
|
44
|
+
| `verify_webhook(raw_body, signature_header, secret, tolerance=300)` | True when a webhook delivery is genuine and fresh. |
|
|
45
|
+
|
|
46
|
+
A file is a path, or `(name, bytes, content_type)`, or `(name, file_object, content_type, size)`.
|
|
47
|
+
A refused request raises `ApiError` (`status`, `code`, `message`, `details`, `retry_after`);
|
|
48
|
+
a job that fails or is cancelled raises `JobFailedError` (`.job`, with `job.error`).
|
|
49
|
+
|
|
50
|
+
The tools, their options and the errors are documented at
|
|
51
|
+
<https://uvpdftool.com/developers>.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "uvpdftool"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "The UV pdfTool API from Python: compress, convert, OCR and check PDFs on UV pdfTool's servers."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "UV ResearchLabs" }]
|
|
13
|
+
requires-python = ">=3.9"
|
|
14
|
+
dependencies = []
|
|
15
|
+
keywords = ["pdf", "compress", "ocr", "convert", "pdfa", "api", "uvpdftool"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Software Development :: Libraries",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://uvpdftool.com/developers"
|
|
25
|
+
Documentation = "https://uvpdftool.com/developers/sdks"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools]
|
|
28
|
+
packages = ["uvpdftool"]
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""The client against a stand-in API: the whole flow, the refusals, the webhook check."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import threading
|
|
10
|
+
import unittest
|
|
11
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
12
|
+
|
|
13
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
14
|
+
|
|
15
|
+
from uvpdftool import ApiError, Client, JobFailedError, verify_webhook # noqa: E402
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class State:
|
|
19
|
+
def __init__(self):
|
|
20
|
+
self.jobs = {}
|
|
21
|
+
self.uploads = {}
|
|
22
|
+
self.requests = []
|
|
23
|
+
self.reads_until_done = 2
|
|
24
|
+
self.fail_next_upload = 0
|
|
25
|
+
self.rate_limit_once = False
|
|
26
|
+
self.origin = ""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
STATE = State()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Handler(BaseHTTPRequestHandler):
|
|
33
|
+
def log_message(self, *_args):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
def _send(self, status, body=None, headers=None):
|
|
37
|
+
self.send_response(status)
|
|
38
|
+
self.send_header("Content-Type", "application/json")
|
|
39
|
+
for key, value in (headers or {}).items():
|
|
40
|
+
self.send_header(key, value)
|
|
41
|
+
payload = json.dumps(body).encode() if body is not None else b""
|
|
42
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
43
|
+
self.end_headers()
|
|
44
|
+
self.wfile.write(payload)
|
|
45
|
+
|
|
46
|
+
def _refuse(self, status, code, message):
|
|
47
|
+
self._send(status, {"error": {"code": code, "message": message}})
|
|
48
|
+
|
|
49
|
+
def _handle(self, method):
|
|
50
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
51
|
+
body = self.rfile.read(length) if length else b""
|
|
52
|
+
STATE.requests.append((method, self.path, self.headers.get("Authorization"), body))
|
|
53
|
+
if self.path.startswith("/upload/"):
|
|
54
|
+
if STATE.fail_next_upload > 0:
|
|
55
|
+
STATE.fail_next_upload -= 1
|
|
56
|
+
self._send(503)
|
|
57
|
+
return
|
|
58
|
+
STATE.uploads[self.path[len("/upload/"):]] = body
|
|
59
|
+
self._send(200)
|
|
60
|
+
return
|
|
61
|
+
if self.path.startswith("/download/"):
|
|
62
|
+
self.send_response(200)
|
|
63
|
+
self.send_header("Content-Type", "application/pdf")
|
|
64
|
+
self.send_header("Content-Length", "15")
|
|
65
|
+
self.end_headers()
|
|
66
|
+
self.wfile.write(b"%PDF-1.7 result")
|
|
67
|
+
return
|
|
68
|
+
if self.path == "/api/v1/tools":
|
|
69
|
+
self._send(200, {"tools": [{"id": "compress"}]})
|
|
70
|
+
return
|
|
71
|
+
auth = self.headers.get("Authorization") or ""
|
|
72
|
+
if not auth.startswith("Bearer uvp_"):
|
|
73
|
+
self._refuse(401, "API_KEY_MISSING", "Send your API key as a Bearer token.")
|
|
74
|
+
return
|
|
75
|
+
if STATE.rate_limit_once:
|
|
76
|
+
STATE.rate_limit_once = False
|
|
77
|
+
self._send(429, {"error": {"code": "TOO_MANY_REQUESTS", "message": "slow down"}}, {"Retry-After": "1"})
|
|
78
|
+
return
|
|
79
|
+
if self.path == "/api/v1/account":
|
|
80
|
+
self._send(200, {"key": {"name": "Test", "mode": "test", "prefix": "uvp_test_abcd", "scopes": ["jobs:read"]}, "tasks": 3, "test": {"left": 49, "perDay": 50}})
|
|
81
|
+
return
|
|
82
|
+
if self.path == "/api/v1/jobs" and method == "POST":
|
|
83
|
+
parsed = json.loads(body)
|
|
84
|
+
if parsed["tool"] == "nope":
|
|
85
|
+
self._refuse(503, "TOOL_UNAVAILABLE", "This tool is not available right now.")
|
|
86
|
+
return
|
|
87
|
+
job_id = f"job-{len(STATE.jobs) + 1}"
|
|
88
|
+
job = {
|
|
89
|
+
"id": job_id,
|
|
90
|
+
"tool": parsed["tool"],
|
|
91
|
+
"mode": "test",
|
|
92
|
+
"state": "created",
|
|
93
|
+
"progress": None,
|
|
94
|
+
"error": None,
|
|
95
|
+
"outputs": [],
|
|
96
|
+
"createdAt": "2026-10-28T10:15:00.000Z",
|
|
97
|
+
"expiresAt": "2026-10-28T10:35:00.000Z",
|
|
98
|
+
}
|
|
99
|
+
STATE.jobs[job_id] = job
|
|
100
|
+
uploads = [
|
|
101
|
+
{"name": f["name"], "method": "PUT", "url": f"{STATE.origin}/upload/{job_id}-{i}", "headers": {"Content-Type": f["contentType"]}}
|
|
102
|
+
for i, f in enumerate(parsed["files"])
|
|
103
|
+
]
|
|
104
|
+
self._send(201, {"job": job, "uploads": uploads})
|
|
105
|
+
return
|
|
106
|
+
parts = self.path.split("/")
|
|
107
|
+
if len(parts) >= 5 and parts[3] == "jobs":
|
|
108
|
+
job = STATE.jobs.get(parts[4])
|
|
109
|
+
if job is None:
|
|
110
|
+
self._refuse(404, "NOT_FOUND", "No such job.")
|
|
111
|
+
return
|
|
112
|
+
if len(parts) == 6 and parts[5] == "start" and method == "POST":
|
|
113
|
+
if not any(key.startswith(job["id"]) for key in STATE.uploads):
|
|
114
|
+
self._refuse(400, "UPLOAD_INCOMPLETE", "A file has not finished uploading.")
|
|
115
|
+
return
|
|
116
|
+
job["state"] = "queued"
|
|
117
|
+
self._send(200, {"job": job})
|
|
118
|
+
return
|
|
119
|
+
if method == "DELETE":
|
|
120
|
+
job["state"] = "cancelled"
|
|
121
|
+
self._send(200, {"job": job})
|
|
122
|
+
return
|
|
123
|
+
if method == "GET":
|
|
124
|
+
if job["state"] in ("queued", "running"):
|
|
125
|
+
STATE.reads_until_done -= 1
|
|
126
|
+
if STATE.reads_until_done > 0:
|
|
127
|
+
job["state"] = "running"
|
|
128
|
+
job["progress"] = 0.5
|
|
129
|
+
elif job["tool"] == "fails":
|
|
130
|
+
job["state"] = "failed"
|
|
131
|
+
job["error"] = {"code": "CORRUPT_PDF", "message": "The PDF is too damaged to read."}
|
|
132
|
+
else:
|
|
133
|
+
job["state"] = "done"
|
|
134
|
+
job["progress"] = 1
|
|
135
|
+
job["outputs"] = [{"name": "report.pdf", "size": 15, "contentType": "application/pdf", "url": f"{STATE.origin}/download/{job['id']}"}]
|
|
136
|
+
self._send(200, {"job": job})
|
|
137
|
+
return
|
|
138
|
+
self._refuse(405, "METHOD", "no")
|
|
139
|
+
|
|
140
|
+
def do_GET(self):
|
|
141
|
+
self._handle("GET")
|
|
142
|
+
|
|
143
|
+
def do_POST(self):
|
|
144
|
+
self._handle("POST")
|
|
145
|
+
|
|
146
|
+
def do_PUT(self):
|
|
147
|
+
self._handle("PUT")
|
|
148
|
+
|
|
149
|
+
def do_DELETE(self):
|
|
150
|
+
self._handle("DELETE")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class ClientTests(unittest.TestCase):
|
|
154
|
+
@classmethod
|
|
155
|
+
def setUpClass(cls):
|
|
156
|
+
cls.server = HTTPServer(("127.0.0.1", 0), Handler)
|
|
157
|
+
STATE.origin = f"http://127.0.0.1:{cls.server.server_port}"
|
|
158
|
+
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
|
|
159
|
+
cls.thread.start()
|
|
160
|
+
cls.client = Client("uvp_test_key", base_url=f"{STATE.origin}/api/v1", timeout=5)
|
|
161
|
+
|
|
162
|
+
@classmethod
|
|
163
|
+
def tearDownClass(cls):
|
|
164
|
+
cls.server.shutdown()
|
|
165
|
+
|
|
166
|
+
def setUp(self):
|
|
167
|
+
STATE.requests.clear()
|
|
168
|
+
STATE.jobs.clear()
|
|
169
|
+
STATE.uploads.clear()
|
|
170
|
+
STATE.reads_until_done = 2
|
|
171
|
+
STATE.fail_next_upload = 0
|
|
172
|
+
STATE.rate_limit_once = False
|
|
173
|
+
|
|
174
|
+
def test_needs_a_key(self):
|
|
175
|
+
with self.assertRaises(TypeError):
|
|
176
|
+
Client("")
|
|
177
|
+
|
|
178
|
+
def test_reads(self):
|
|
179
|
+
self.assertEqual(self.client.tools()[0]["id"], "compress")
|
|
180
|
+
self.assertEqual(self.client.account()["tasks"], 3)
|
|
181
|
+
self.assertEqual(STATE.requests[-1][2], "Bearer uvp_test_key")
|
|
182
|
+
|
|
183
|
+
def test_runs_a_job_from_a_path_and_downloads(self):
|
|
184
|
+
seen = []
|
|
185
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
186
|
+
path = os.path.join(folder, "report.pdf")
|
|
187
|
+
with open(path, "wb") as handle:
|
|
188
|
+
handle.write(b"%PDF-1.7 input")
|
|
189
|
+
job = self.client.run(
|
|
190
|
+
"compress", [path], options={"method": "ghostscript"}, retention_hours=5,
|
|
191
|
+
on_progress=lambda fraction, _job: seen.append(fraction),
|
|
192
|
+
)
|
|
193
|
+
self.assertEqual(job.state, "done")
|
|
194
|
+
self.assertEqual(seen, [0.5, 1])
|
|
195
|
+
methods = [(m, p) for m, p, _a, _b in STATE.requests]
|
|
196
|
+
self.assertEqual(methods, [
|
|
197
|
+
("POST", "/api/v1/jobs"),
|
|
198
|
+
("PUT", "/upload/job-1-0"),
|
|
199
|
+
("POST", "/api/v1/jobs/job-1/start"),
|
|
200
|
+
("GET", "/api/v1/jobs/job-1"),
|
|
201
|
+
("GET", "/api/v1/jobs/job-1"),
|
|
202
|
+
])
|
|
203
|
+
created = json.loads(STATE.requests[0][3])
|
|
204
|
+
self.assertEqual(created, {
|
|
205
|
+
"tool": "compress",
|
|
206
|
+
"files": [{"name": "report.pdf", "size": 14, "contentType": "application/pdf"}],
|
|
207
|
+
"options": {"method": "ghostscript"},
|
|
208
|
+
"retentionHours": 5,
|
|
209
|
+
})
|
|
210
|
+
self.assertEqual(STATE.uploads["job-1-0"], b"%PDF-1.7 input")
|
|
211
|
+
out = os.path.join(folder, "out.pdf")
|
|
212
|
+
self.client.download(job.outputs[0], out)
|
|
213
|
+
with open(out, "rb") as handle:
|
|
214
|
+
self.assertEqual(handle.read(), b"%PDF-1.7 result")
|
|
215
|
+
self.assertEqual(self.client.download(job.outputs[0]), b"%PDF-1.7 result")
|
|
216
|
+
|
|
217
|
+
def test_bytes_and_failures(self):
|
|
218
|
+
with self.assertRaises(JobFailedError) as failed:
|
|
219
|
+
self.client.run("fails", [("a.pdf", b"123", "application/pdf")])
|
|
220
|
+
self.assertEqual(failed.exception.job.error["code"], "CORRUPT_PDF")
|
|
221
|
+
with self.assertRaises(ApiError) as refused:
|
|
222
|
+
self.client.run("nope", [("a.pdf", b"1", "application/pdf")])
|
|
223
|
+
self.assertEqual((refused.exception.status, refused.exception.code), (503, "TOOL_UNAVAILABLE"))
|
|
224
|
+
|
|
225
|
+
def test_upload_retry_and_cancel(self):
|
|
226
|
+
STATE.fail_next_upload = 1
|
|
227
|
+
job = self.client.run("compress", [("b.pdf", b"9", "application/pdf")])
|
|
228
|
+
self.assertEqual(job.state, "done")
|
|
229
|
+
self.assertEqual(len([r for r in STATE.requests if r[0] == "PUT"]), 2)
|
|
230
|
+
STATE.fail_next_upload = 3
|
|
231
|
+
STATE.requests.clear()
|
|
232
|
+
with self.assertRaises(ApiError) as failed:
|
|
233
|
+
self.client.run("compress", [("c.pdf", b"9", "application/pdf")])
|
|
234
|
+
self.assertEqual(failed.exception.code, "UPLOAD_FAILED")
|
|
235
|
+
self.assertEqual(STATE.requests[-1][0], "DELETE")
|
|
236
|
+
|
|
237
|
+
def test_rate_limit_waited_out_once(self):
|
|
238
|
+
STATE.rate_limit_once = True
|
|
239
|
+
self.assertEqual(self.client.account()["tasks"], 3)
|
|
240
|
+
self.assertEqual(len(STATE.requests), 2)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class WebhookTests(unittest.TestCase):
|
|
244
|
+
def test_verify(self):
|
|
245
|
+
secret = "whsec_test"
|
|
246
|
+
body = b'{"id":"1","type":"ping"}'
|
|
247
|
+
t = 1761646572
|
|
248
|
+
|
|
249
|
+
def sign(at):
|
|
250
|
+
digest = hmac.new(secret.encode(), f"{at}.".encode() + body, hashlib.sha256).hexdigest()
|
|
251
|
+
return f"t={at},v1={digest}"
|
|
252
|
+
|
|
253
|
+
now = float(t)
|
|
254
|
+
self.assertTrue(verify_webhook(body, sign(t), secret, now=now))
|
|
255
|
+
self.assertFalse(verify_webhook(body + b" ", sign(t), secret, now=now))
|
|
256
|
+
self.assertFalse(verify_webhook(body, sign(t), "other", now=now))
|
|
257
|
+
self.assertFalse(verify_webhook(body, sign(t - 1000), secret, now=now))
|
|
258
|
+
self.assertFalse(verify_webhook(body, "nonsense", secret, now=now))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
if __name__ == "__main__":
|
|
262
|
+
unittest.main()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""The UV pdfTool API from Python: https://uvpdftool.com/developers"""
|
|
2
|
+
|
|
3
|
+
from .client import (
|
|
4
|
+
DEFAULT_BASE_URL,
|
|
5
|
+
ApiError,
|
|
6
|
+
Client,
|
|
7
|
+
CreatedJob,
|
|
8
|
+
Job,
|
|
9
|
+
JobFailedError,
|
|
10
|
+
Output,
|
|
11
|
+
Upload,
|
|
12
|
+
verify_webhook,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"DEFAULT_BASE_URL",
|
|
17
|
+
"ApiError",
|
|
18
|
+
"Client",
|
|
19
|
+
"CreatedJob",
|
|
20
|
+
"Job",
|
|
21
|
+
"JobFailedError",
|
|
22
|
+
"Output",
|
|
23
|
+
"Upload",
|
|
24
|
+
"verify_webhook",
|
|
25
|
+
]
|
|
26
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The UV pdfTool API from Python 3.9 and later, with the standard library only: create a job,
|
|
3
|
+
upload the files, start it, wait for it, download the result, or do all of that with one
|
|
4
|
+
call. Documentation: https://uvpdftool.com/developers
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import hmac
|
|
11
|
+
import io
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import time
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any, BinaryIO, Callable, Dict, List, Optional, Sequence, Tuple, Union
|
|
19
|
+
|
|
20
|
+
DEFAULT_BASE_URL = "https://uvpdftool.com/api/v1"
|
|
21
|
+
__version__ = "0.1.0"
|
|
22
|
+
|
|
23
|
+
ENDED = ("done", "failed", "cancelled")
|
|
24
|
+
|
|
25
|
+
FileArg = Union[str, "os.PathLike[str]", Tuple[str, bytes, str], Tuple[str, BinaryIO, str, int]]
|
|
26
|
+
"""A file to send: a path, or (name, bytes, content_type), or (name, file object, content_type, size)."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ApiError(Exception):
|
|
30
|
+
"""A refused request: the status, the code to switch on, the message to show."""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
status: int,
|
|
35
|
+
code: str,
|
|
36
|
+
message: str,
|
|
37
|
+
details: Optional[Dict[str, Any]] = None,
|
|
38
|
+
retry_after: Optional[int] = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
super().__init__(f"{code}: {message}")
|
|
41
|
+
self.status = status
|
|
42
|
+
self.code = code
|
|
43
|
+
self.message = message
|
|
44
|
+
self.details = details or {}
|
|
45
|
+
self.retry_after = retry_after
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Output:
|
|
50
|
+
name: str
|
|
51
|
+
size: int
|
|
52
|
+
content_type: str
|
|
53
|
+
url: Optional[str] = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Job:
|
|
58
|
+
id: str
|
|
59
|
+
tool: str
|
|
60
|
+
mode: str
|
|
61
|
+
state: str
|
|
62
|
+
progress: Optional[float]
|
|
63
|
+
error: Optional[Dict[str, Any]]
|
|
64
|
+
outputs: List[Output]
|
|
65
|
+
created_at: str
|
|
66
|
+
expires_at: str
|
|
67
|
+
raw: Dict[str, Any] = field(default_factory=dict, repr=False)
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def from_json(data: Dict[str, Any]) -> "Job":
|
|
71
|
+
return Job(
|
|
72
|
+
id=data["id"],
|
|
73
|
+
tool=data["tool"],
|
|
74
|
+
mode=data["mode"],
|
|
75
|
+
state=data["state"],
|
|
76
|
+
progress=data.get("progress"),
|
|
77
|
+
error=data.get("error"),
|
|
78
|
+
outputs=[
|
|
79
|
+
Output(o["name"], o["size"], o["contentType"], o.get("url"))
|
|
80
|
+
for o in data.get("outputs", [])
|
|
81
|
+
],
|
|
82
|
+
created_at=data["createdAt"],
|
|
83
|
+
expires_at=data["expiresAt"],
|
|
84
|
+
raw=data,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class Upload:
|
|
90
|
+
name: str
|
|
91
|
+
method: str
|
|
92
|
+
url: str
|
|
93
|
+
headers: Dict[str, str]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class CreatedJob:
|
|
98
|
+
job: Job
|
|
99
|
+
uploads: List[Upload]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class JobFailedError(Exception):
|
|
103
|
+
"""A job that ended failed or cancelled; `job.error` says why."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, job: Job) -> None:
|
|
106
|
+
if job.error:
|
|
107
|
+
super().__init__(f"{job.error.get('code')}: {job.error.get('message')}")
|
|
108
|
+
else:
|
|
109
|
+
super().__init__(f"The job ended {job.state} without a result.")
|
|
110
|
+
self.job = job
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _normalise(file: FileArg) -> Tuple[str, Union[bytes, BinaryIO], str, int]:
|
|
114
|
+
if isinstance(file, (str, os.PathLike)):
|
|
115
|
+
path = os.fspath(file)
|
|
116
|
+
name = os.path.basename(path)
|
|
117
|
+
return name, open(path, "rb"), _content_type(name), os.path.getsize(path)
|
|
118
|
+
if len(file) == 3:
|
|
119
|
+
name, data, content_type = file # type: ignore[misc]
|
|
120
|
+
if not isinstance(data, (bytes, bytearray)):
|
|
121
|
+
raise TypeError(f"{name}: give bytes, or a file object with its size.")
|
|
122
|
+
return name, bytes(data), content_type, len(data)
|
|
123
|
+
name, handle, content_type, size = file # type: ignore[misc]
|
|
124
|
+
return name, handle, content_type, int(size)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
_TYPES = {
|
|
128
|
+
".pdf": "application/pdf",
|
|
129
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
130
|
+
".doc": "application/msword",
|
|
131
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
132
|
+
".xls": "application/vnd.ms-excel",
|
|
133
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
134
|
+
".ppt": "application/vnd.ms-powerpoint",
|
|
135
|
+
".odt": "application/vnd.oasis.opendocument.text",
|
|
136
|
+
".ods": "application/vnd.oasis.opendocument.spreadsheet",
|
|
137
|
+
".odp": "application/vnd.oasis.opendocument.presentation",
|
|
138
|
+
".rtf": "application/rtf",
|
|
139
|
+
".txt": "text/plain",
|
|
140
|
+
".csv": "text/csv",
|
|
141
|
+
".html": "text/html",
|
|
142
|
+
".htm": "text/html",
|
|
143
|
+
".md": "text/markdown",
|
|
144
|
+
".epub": "application/epub+zip",
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _content_type(name: str) -> str:
|
|
149
|
+
return _TYPES.get(os.path.splitext(name)[1].lower(), "application/octet-stream")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class Client:
|
|
153
|
+
"""The API client. `api_key` is uvp_live_… or uvp_test_…."""
|
|
154
|
+
|
|
155
|
+
def __init__(
|
|
156
|
+
self,
|
|
157
|
+
api_key: str,
|
|
158
|
+
base_url: Optional[str] = None,
|
|
159
|
+
timeout: float = 60.0,
|
|
160
|
+
user_agent: Optional[str] = None,
|
|
161
|
+
) -> None:
|
|
162
|
+
if not api_key:
|
|
163
|
+
raise TypeError("An API key is needed: uvp_live_… or uvp_test_…")
|
|
164
|
+
self.api_key = api_key
|
|
165
|
+
self.base_url = (base_url or DEFAULT_BASE_URL).rstrip("/")
|
|
166
|
+
self.timeout = timeout
|
|
167
|
+
self.user_agent = f"uvpdftool-python/{__version__}" + (f" {user_agent}" if user_agent else "")
|
|
168
|
+
|
|
169
|
+
# ------------------------------------------------------------------ reads
|
|
170
|
+
|
|
171
|
+
def tools(self) -> List[Dict[str, Any]]:
|
|
172
|
+
"""The tools the API offers, with their limits and options."""
|
|
173
|
+
return self._request("GET", "/tools")["tools"]
|
|
174
|
+
|
|
175
|
+
def account(self) -> Dict[str, Any]:
|
|
176
|
+
"""The key in use, the API tasks left, and today's test jobs."""
|
|
177
|
+
return self._request("GET", "/account")
|
|
178
|
+
|
|
179
|
+
# ------------------------------------------------------------------ jobs
|
|
180
|
+
|
|
181
|
+
def create_job(
|
|
182
|
+
self,
|
|
183
|
+
tool: str,
|
|
184
|
+
files: Sequence[FileArg],
|
|
185
|
+
options: Optional[Dict[str, Any]] = None,
|
|
186
|
+
retention_hours: Optional[int] = None,
|
|
187
|
+
) -> CreatedJob:
|
|
188
|
+
"""POST /jobs with the files declared; the answer holds an upload link per file."""
|
|
189
|
+
declared = []
|
|
190
|
+
for file in files:
|
|
191
|
+
name, data, content_type, size = _normalise(file)
|
|
192
|
+
if isinstance(data, io.IOBase) and isinstance(file, (str, os.PathLike)):
|
|
193
|
+
data.close()
|
|
194
|
+
declared.append({"name": name, "size": size, "contentType": content_type})
|
|
195
|
+
body: Dict[str, Any] = {"tool": tool, "files": declared}
|
|
196
|
+
if options is not None:
|
|
197
|
+
body["options"] = options
|
|
198
|
+
if retention_hours is not None:
|
|
199
|
+
body["retentionHours"] = retention_hours
|
|
200
|
+
data = self._request("POST", "/jobs", body)
|
|
201
|
+
return CreatedJob(
|
|
202
|
+
job=Job.from_json(data["job"]),
|
|
203
|
+
uploads=[Upload(u["name"], u["method"], u["url"], u["headers"]) for u in data["uploads"]],
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
def upload(self, upload: Upload, data: Union[bytes, BinaryIO, str, "os.PathLike[str]"]) -> None:
|
|
207
|
+
"""PUTs the bytes (or a file's) to one upload link, as they are."""
|
|
208
|
+
if isinstance(data, (str, os.PathLike)):
|
|
209
|
+
with open(data, "rb") as handle:
|
|
210
|
+
payload: Union[bytes, BinaryIO] = handle.read()
|
|
211
|
+
else:
|
|
212
|
+
payload = data
|
|
213
|
+
if not isinstance(payload, (bytes, bytearray)):
|
|
214
|
+
payload = payload.read()
|
|
215
|
+
for attempt in range(1, 4):
|
|
216
|
+
request = urllib.request.Request(upload.url, data=bytes(payload), method=upload.method)
|
|
217
|
+
for key, value in upload.headers.items():
|
|
218
|
+
request.add_header(key, value)
|
|
219
|
+
request.add_header("Content-Length", str(len(payload)))
|
|
220
|
+
try:
|
|
221
|
+
with urllib.request.urlopen(request, timeout=self.timeout * 10):
|
|
222
|
+
return
|
|
223
|
+
except urllib.error.HTTPError as error:
|
|
224
|
+
if error.code < 500 or attempt == 3:
|
|
225
|
+
raise ApiError(error.code, "UPLOAD_FAILED", f"The upload of {upload.name} answered {error.code}.")
|
|
226
|
+
except urllib.error.URLError:
|
|
227
|
+
if attempt == 3:
|
|
228
|
+
raise
|
|
229
|
+
time.sleep(0.5 * attempt)
|
|
230
|
+
|
|
231
|
+
def start_job(self, job_id: str) -> Job:
|
|
232
|
+
return Job.from_json(self._request("POST", f"/jobs/{job_id}/start")["job"])
|
|
233
|
+
|
|
234
|
+
def get_job(self, job_id: str) -> Job:
|
|
235
|
+
return Job.from_json(self._request("GET", f"/jobs/{job_id}")["job"])
|
|
236
|
+
|
|
237
|
+
def cancel_job(self, job_id: str) -> Job:
|
|
238
|
+
return Job.from_json(self._request("DELETE", f"/jobs/{job_id}")["job"])
|
|
239
|
+
|
|
240
|
+
def wait(
|
|
241
|
+
self,
|
|
242
|
+
job_id: str,
|
|
243
|
+
interval: float = 2.0,
|
|
244
|
+
timeout: Optional[float] = None,
|
|
245
|
+
on_progress: Optional[Callable[[float, Job], None]] = None,
|
|
246
|
+
) -> Job:
|
|
247
|
+
"""Reads the job until it ends; returns it done, or raises JobFailedError."""
|
|
248
|
+
started = time.monotonic()
|
|
249
|
+
last = -1.0
|
|
250
|
+
while True:
|
|
251
|
+
job = self.get_job(job_id)
|
|
252
|
+
if job.progress is not None and job.progress != last and on_progress:
|
|
253
|
+
last = job.progress
|
|
254
|
+
on_progress(job.progress, job)
|
|
255
|
+
if job.state == "done":
|
|
256
|
+
return job
|
|
257
|
+
if job.state in ENDED:
|
|
258
|
+
raise JobFailedError(job)
|
|
259
|
+
waited = time.monotonic() - started
|
|
260
|
+
if timeout is not None and waited > timeout:
|
|
261
|
+
raise TimeoutError(f"Still {job.state} after {int(waited)} s.")
|
|
262
|
+
time.sleep(interval if waited < 60 or interval != 2.0 else 5.0)
|
|
263
|
+
|
|
264
|
+
def run(
|
|
265
|
+
self,
|
|
266
|
+
tool: str,
|
|
267
|
+
files: Sequence[FileArg],
|
|
268
|
+
options: Optional[Dict[str, Any]] = None,
|
|
269
|
+
retention_hours: Optional[int] = None,
|
|
270
|
+
timeout: Optional[float] = None,
|
|
271
|
+
on_progress: Optional[Callable[[float, Job], None]] = None,
|
|
272
|
+
) -> Job:
|
|
273
|
+
"""Creates the job, uploads every file, starts it and waits for it."""
|
|
274
|
+
created = self.create_job(tool, files, options, retention_hours)
|
|
275
|
+
try:
|
|
276
|
+
for upload, file in zip(created.uploads, files):
|
|
277
|
+
name, data, _content_type, _size = _normalise(file)
|
|
278
|
+
try:
|
|
279
|
+
self.upload(upload, data)
|
|
280
|
+
finally:
|
|
281
|
+
if isinstance(data, io.IOBase) and isinstance(file, (str, os.PathLike)):
|
|
282
|
+
data.close()
|
|
283
|
+
self.start_job(created.job.id)
|
|
284
|
+
except Exception:
|
|
285
|
+
# A job without its files would wait twenty minutes for them; end it now.
|
|
286
|
+
try:
|
|
287
|
+
self.cancel_job(created.job.id)
|
|
288
|
+
except Exception:
|
|
289
|
+
pass
|
|
290
|
+
raise
|
|
291
|
+
return self.wait(created.job.id, timeout=timeout, on_progress=on_progress)
|
|
292
|
+
|
|
293
|
+
def download(self, output: Output, path: Optional[Union[str, "os.PathLike[str]"]] = None) -> Optional[bytes]:
|
|
294
|
+
"""The bytes of one output of a finished job, or written to `path`."""
|
|
295
|
+
if not output.url:
|
|
296
|
+
raise TypeError(f"{output.name} has no download link yet.")
|
|
297
|
+
request = urllib.request.Request(output.url, headers={"User-Agent": self.user_agent})
|
|
298
|
+
with urllib.request.urlopen(request, timeout=self.timeout * 10) as response:
|
|
299
|
+
data = response.read()
|
|
300
|
+
if path is None:
|
|
301
|
+
return data
|
|
302
|
+
with open(path, "wb") as handle:
|
|
303
|
+
handle.write(data)
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
# ------------------------------------------------------------------ plumbing
|
|
307
|
+
|
|
308
|
+
def _request(self, method: str, path: str, body: Optional[Dict[str, Any]] = None) -> Any:
|
|
309
|
+
for attempt in range(1, 3):
|
|
310
|
+
payload = json.dumps(body).encode() if body is not None else None
|
|
311
|
+
request = urllib.request.Request(self.base_url + path, data=payload, method=method)
|
|
312
|
+
request.add_header("Authorization", f"Bearer {self.api_key}")
|
|
313
|
+
request.add_header("Accept", "application/json")
|
|
314
|
+
request.add_header("User-Agent", self.user_agent)
|
|
315
|
+
if payload is not None:
|
|
316
|
+
request.add_header("Content-Type", "application/json")
|
|
317
|
+
try:
|
|
318
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
319
|
+
text = response.read()
|
|
320
|
+
return json.loads(text) if text else None
|
|
321
|
+
except urllib.error.HTTPError as error:
|
|
322
|
+
text = error.read()
|
|
323
|
+
try:
|
|
324
|
+
problem = json.loads(text).get("error") or {}
|
|
325
|
+
except (ValueError, AttributeError):
|
|
326
|
+
problem = {}
|
|
327
|
+
retry_after = error.headers.get("Retry-After")
|
|
328
|
+
seconds = int(retry_after) if retry_after and retry_after.isdigit() else None
|
|
329
|
+
failure = ApiError(
|
|
330
|
+
error.code,
|
|
331
|
+
problem.get("code", "HTTP_ERROR"),
|
|
332
|
+
problem.get("message", f"The API answered {error.code}."),
|
|
333
|
+
problem.get("details") or {},
|
|
334
|
+
seconds,
|
|
335
|
+
)
|
|
336
|
+
# One minute's worth of requests spent: wait what the API asks, once.
|
|
337
|
+
if failure.code == "TOO_MANY_REQUESTS" and attempt == 1 and seconds:
|
|
338
|
+
time.sleep(min(seconds, 60))
|
|
339
|
+
continue
|
|
340
|
+
raise failure from None
|
|
341
|
+
raise AssertionError("unreachable")
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def verify_webhook(raw_body: bytes, signature_header: str, secret: str, tolerance: int = 300, now: Optional[float] = None) -> bool:
|
|
345
|
+
"""True when a webhook body came from UV pdfTool, unchanged, within `tolerance` seconds of now."""
|
|
346
|
+
parts: Dict[str, str] = {}
|
|
347
|
+
for part in signature_header.split(","):
|
|
348
|
+
if "=" in part:
|
|
349
|
+
key, value = part.split("=", 1)
|
|
350
|
+
parts[key.strip()] = value.strip()
|
|
351
|
+
try:
|
|
352
|
+
t = int(parts["t"])
|
|
353
|
+
except (KeyError, ValueError):
|
|
354
|
+
return False
|
|
355
|
+
if abs((now if now is not None else time.time()) - t) > tolerance:
|
|
356
|
+
return False
|
|
357
|
+
if isinstance(raw_body, str):
|
|
358
|
+
raw_body = raw_body.encode("utf-8")
|
|
359
|
+
expected = hmac.new(secret.encode("utf-8"), f"{t}.".encode("utf-8") + raw_body, hashlib.sha256).hexdigest()
|
|
360
|
+
return hmac.compare_digest(expected, parts.get("v1", ""))
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: uvpdftool
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The UV pdfTool API from Python: compress, convert, OCR and check PDFs on UV pdfTool's servers.
|
|
5
|
+
Author: UV ResearchLabs
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://uvpdftool.com/developers
|
|
8
|
+
Project-URL: Documentation, https://uvpdftool.com/developers/sdks
|
|
9
|
+
Keywords: pdf,compress,ocr,convert,pdfa,api,uvpdftool
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# uvpdftool
|
|
20
|
+
|
|
21
|
+
The [UV pdfTool API](https://uvpdftool.com/developers) from Python 3.9 and later: compress,
|
|
22
|
+
convert, OCR and check PDFs on UV pdfTool's servers. The standard library only; type hints
|
|
23
|
+
included.
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
pip install uvpdftool
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import os
|
|
31
|
+
|
|
32
|
+
from uvpdftool import Client, JobFailedError
|
|
33
|
+
|
|
34
|
+
client = Client(api_key=os.environ["UVPDF_KEY"])
|
|
35
|
+
|
|
36
|
+
# Creates the job, uploads the file, starts it and waits for it.
|
|
37
|
+
job = client.run(
|
|
38
|
+
"compress",
|
|
39
|
+
files=["report.pdf"],
|
|
40
|
+
options={"method": "ghostscript", "preset": "ebook"},
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
for output in job.outputs:
|
|
44
|
+
client.download(output, output.name)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Make a key on the [API keys page](https://uvpdftool.com/developers/keys); a test key is free
|
|
48
|
+
and marks its results. Keep the key on your server, never in a browser or an app.
|
|
49
|
+
|
|
50
|
+
## What it does
|
|
51
|
+
|
|
52
|
+
| Call | Does |
|
|
53
|
+
| --------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
|
54
|
+
| `Client(api_key, base_url=None, timeout=60)` | The client. |
|
|
55
|
+
| `client.run(tool, files, options=None, retention_hours=None, timeout=None, on_progress=None)` | Create, upload, start, wait; returns the finished job or raises `JobFailedError`. |
|
|
56
|
+
| `client.download(output, path=None)` | The bytes of one output, or written to `path`. |
|
|
57
|
+
| `client.create_job(tool, files, options=None, retention_hours=None)` | `POST /jobs`; `.job` and `.uploads`. |
|
|
58
|
+
| `client.upload(upload, data)` | `PUT` the bytes (or a path's) to one link. |
|
|
59
|
+
| `client.start_job(id)`, `client.get_job(id)`, `client.cancel_job(id)` | The three job calls. |
|
|
60
|
+
| `client.wait(id, interval=2.0, timeout=None, on_progress=None)` | Polls until the job ends. |
|
|
61
|
+
| `client.tools()`, `client.account()` | `GET /tools`, `GET /account`. |
|
|
62
|
+
| `verify_webhook(raw_body, signature_header, secret, tolerance=300)` | True when a webhook delivery is genuine and fresh. |
|
|
63
|
+
|
|
64
|
+
A file is a path, or `(name, bytes, content_type)`, or `(name, file_object, content_type, size)`.
|
|
65
|
+
A refused request raises `ApiError` (`status`, `code`, `message`, `details`, `retry_after`);
|
|
66
|
+
a job that fails or is cancelled raises `JobFailedError` (`.job`, with `job.error`).
|
|
67
|
+
|
|
68
|
+
The tools, their options and the errors are documented at
|
|
69
|
+
<https://uvpdftool.com/developers>.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
uvpdftool
|