uvpdftool 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
uvpdftool/__init__.py ADDED
@@ -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"
uvpdftool/client.py ADDED
@@ -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,7 @@
1
+ uvpdftool/__init__.py,sha256=zFxZaOZjdJtZTCz3V4VmD7j7wuPtsA1h2HXwqEULACo,425
2
+ uvpdftool/client.py,sha256=B2uRFkJbksxZd3GY9XBoet09dUzNxhbXT07vdhanDJY,13894
3
+ uvpdftool-0.1.0.dist-info/licenses/LICENSE,sha256=xTZ84sfkh-WhOZ4GQbFM4nzuALxAS-2iDQMF9lrmyUc,1072
4
+ uvpdftool-0.1.0.dist-info/METADATA,sha256=JViiwN1HmrDdOoBNsMHwIgJsanHTPf_QMKi9kPoCt_8,3931
5
+ uvpdftool-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
6
+ uvpdftool-0.1.0.dist-info/top_level.txt,sha256=r7rAaOPa3-cCIXiKEJt3x9dEm67GEKoemJf-Lkyd4fc,10
7
+ uvpdftool-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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.
@@ -0,0 +1 @@
1
+ uvpdftool