privconvert 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.
- privconvert-0.1.0/.gitignore +6 -0
- privconvert-0.1.0/LICENSE +21 -0
- privconvert-0.1.0/PKG-INFO +146 -0
- privconvert-0.1.0/README.md +125 -0
- privconvert-0.1.0/pyproject.toml +38 -0
- privconvert-0.1.0/src/privconvert/__init__.py +45 -0
- privconvert-0.1.0/src/privconvert/_client.py +498 -0
- privconvert-0.1.0/src/privconvert/_errors.py +94 -0
- privconvert-0.1.0/src/privconvert/_version.py +1 -0
- privconvert-0.1.0/src/privconvert/_webhooks.py +54 -0
- privconvert-0.1.0/src/privconvert/py.typed +0 -0
- privconvert-0.1.0/tests/live_check.py +72 -0
- privconvert-0.1.0/tests/test_offline.py +147 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PrivConvert
|
|
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,146 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: privconvert
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python client for the PrivConvert file conversion API. Files are processed in memory and never stored.
|
|
5
|
+
Project-URL: Homepage, https://privconvert.com/developers/
|
|
6
|
+
Project-URL: Documentation, https://privconvert.com/docs/
|
|
7
|
+
Project-URL: Source, https://github.com/toprivHQ/privconvert-python
|
|
8
|
+
Author-email: PrivConvert <contact@topriv.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: api,converter,file conversion,pdf,privacy,privconvert
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Office/Business
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# PrivConvert for Python
|
|
23
|
+
|
|
24
|
+
Official Python client for the [PrivConvert API](https://privconvert.com/developers/): file conversion for your app with zero data retention. Every file is processed in memory on our side and never written to disk.
|
|
25
|
+
|
|
26
|
+
- No dependencies, standard library only
|
|
27
|
+
- Python 3.10+
|
|
28
|
+
- Large files (above 90 MB) are uploaded in parts automatically
|
|
29
|
+
- Async jobs, webhook signature verification and typed errors included
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install privconvert
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Quick start
|
|
38
|
+
|
|
39
|
+
Create a key on your [account page](https://privconvert.com/account/#apikeys) (new accounts can start a free trial of 25 conversions) and set it as an environment variable:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
export PRIVCONVERT_API_KEY=pk_live_...
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from privconvert import PrivConvert
|
|
47
|
+
|
|
48
|
+
pc = PrivConvert()
|
|
49
|
+
|
|
50
|
+
result = pc.convert("word-to-pdf", "contract.docx")
|
|
51
|
+
result.save("contract.pdf")
|
|
52
|
+
print(result.credits_remaining)
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`file` can be a path, `bytes`, or a binary file object. Extra keyword arguments are sent as form fields:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
pc.convert("compress-image", open("photo.png", "rb"), quality=70).save("photo-small.png")
|
|
59
|
+
pc.convert("resize-image", image_bytes, filename="in.jpg", width=800)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The tool id is the same slug as on the site. List them with `pc.tools()` or `pc.tools(category="document")`.
|
|
63
|
+
|
|
64
|
+
## Many files at once
|
|
65
|
+
|
|
66
|
+
Up to 20 files in one request, returned as a ZIP. A batch costs one slot of your per-minute rate limit.
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
zip_result = pc.convert_batch("png-to-jpg", ["a.png", "b.png", "c.png"])
|
|
70
|
+
zip_result.save("converted.zip")
|
|
71
|
+
if zip_result.partial:
|
|
72
|
+
print(zip_result.failed_count, "files were skipped, see PRIVCONVERT-ERRORS.txt in the ZIP")
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Async jobs
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
job = pc.submit_job("pptx-to-pdf", "deck.pptx")
|
|
79
|
+
# job.key is returned once. Keep it if you need to download later or verify webhooks.
|
|
80
|
+
result = pc.wait(job) # polls, downloads, then deletes the stored result
|
|
81
|
+
result.save("deck.pdf")
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
With a webhook instead of polling:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
job = pc.submit_job("pptx-to-pdf", "deck.pptx", webhook_url="https://example.com/hooks/privconvert")
|
|
88
|
+
save_somewhere(job.id, job.key)
|
|
89
|
+
# later, when the webhook arrives:
|
|
90
|
+
result = pc.download_job(job)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Verifying webhooks
|
|
94
|
+
|
|
95
|
+
Pass the raw request bytes, not parsed JSON:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from privconvert import verify_webhook
|
|
99
|
+
|
|
100
|
+
@app.post("/hooks/privconvert")
|
|
101
|
+
def hook():
|
|
102
|
+
job_key = load_job_key(request.json["id"])
|
|
103
|
+
if not verify_webhook(job_key, request.headers.get("X-PrivConvert-Signature"), request.get_data()):
|
|
104
|
+
abort(400)
|
|
105
|
+
...
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Deliveries older than 5 minutes are rejected as replays (`tolerance_s` to change it).
|
|
109
|
+
|
|
110
|
+
## Errors
|
|
111
|
+
|
|
112
|
+
Every error is a `PrivConvertError` with `status`, `code`, `message` and `retry_after`:
|
|
113
|
+
|
|
114
|
+
| Exception | When |
|
|
115
|
+
| --- | --- |
|
|
116
|
+
| `AuthenticationError` | Missing or invalid key (401) |
|
|
117
|
+
| `BadRequestError` | Bad request or unreadable input (400) |
|
|
118
|
+
| `FileTooLargeError` | Above your plan's size limit (413) |
|
|
119
|
+
| `UnsupportedTypeError` | Wrong input type for the tool (415) |
|
|
120
|
+
| `ConversionFailedError` | File read but could not be converted (422) |
|
|
121
|
+
| `RateLimitError` | Per-minute rate or monthly quota exceeded (429) |
|
|
122
|
+
| `ConcurrencyLimitError` | Too many conversions at once (429) |
|
|
123
|
+
| `ServerError` | Problem on our side (5xx) |
|
|
124
|
+
|
|
125
|
+
Short 429 and 503 responses are retried automatically (`max_retries=2`), honouring `Retry-After`. A long `Retry-After`, which means the monthly quota is used up, raises at once instead of sleeping.
|
|
126
|
+
|
|
127
|
+
## Usage and limits
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
pc.me()
|
|
131
|
+
# {'tier': 'growth', 'quota': 50000, 'used': 1234, 'remaining': 48766, 'rpm': 120, ...}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Security notes
|
|
135
|
+
|
|
136
|
+
- The key is read from `PRIVCONVERT_API_KEY` or passed explicitly, and never appears in `repr()` or error messages.
|
|
137
|
+
- Requests go over HTTPS only. A plain `http://` base URL is refused.
|
|
138
|
+
- This package writes nothing to disk except where you call `Result.save()`.
|
|
139
|
+
|
|
140
|
+
## Links
|
|
141
|
+
|
|
142
|
+
- [API documentation](https://privconvert.com/docs/)
|
|
143
|
+
- [Pricing and free trial](https://privconvert.com/developers/)
|
|
144
|
+
- Support: contact@topriv.com
|
|
145
|
+
|
|
146
|
+
MIT License
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# PrivConvert for Python
|
|
2
|
+
|
|
3
|
+
Official Python client for the [PrivConvert API](https://privconvert.com/developers/): file conversion for your app with zero data retention. Every file is processed in memory on our side and never written to disk.
|
|
4
|
+
|
|
5
|
+
- No dependencies, standard library only
|
|
6
|
+
- Python 3.10+
|
|
7
|
+
- Large files (above 90 MB) are uploaded in parts automatically
|
|
8
|
+
- Async jobs, webhook signature verification and typed errors included
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install privconvert
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Quick start
|
|
17
|
+
|
|
18
|
+
Create a key on your [account page](https://privconvert.com/account/#apikeys) (new accounts can start a free trial of 25 conversions) and set it as an environment variable:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
export PRIVCONVERT_API_KEY=pk_live_...
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from privconvert import PrivConvert
|
|
26
|
+
|
|
27
|
+
pc = PrivConvert()
|
|
28
|
+
|
|
29
|
+
result = pc.convert("word-to-pdf", "contract.docx")
|
|
30
|
+
result.save("contract.pdf")
|
|
31
|
+
print(result.credits_remaining)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`file` can be a path, `bytes`, or a binary file object. Extra keyword arguments are sent as form fields:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
pc.convert("compress-image", open("photo.png", "rb"), quality=70).save("photo-small.png")
|
|
38
|
+
pc.convert("resize-image", image_bytes, filename="in.jpg", width=800)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The tool id is the same slug as on the site. List them with `pc.tools()` or `pc.tools(category="document")`.
|
|
42
|
+
|
|
43
|
+
## Many files at once
|
|
44
|
+
|
|
45
|
+
Up to 20 files in one request, returned as a ZIP. A batch costs one slot of your per-minute rate limit.
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
zip_result = pc.convert_batch("png-to-jpg", ["a.png", "b.png", "c.png"])
|
|
49
|
+
zip_result.save("converted.zip")
|
|
50
|
+
if zip_result.partial:
|
|
51
|
+
print(zip_result.failed_count, "files were skipped, see PRIVCONVERT-ERRORS.txt in the ZIP")
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Async jobs
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
job = pc.submit_job("pptx-to-pdf", "deck.pptx")
|
|
58
|
+
# job.key is returned once. Keep it if you need to download later or verify webhooks.
|
|
59
|
+
result = pc.wait(job) # polls, downloads, then deletes the stored result
|
|
60
|
+
result.save("deck.pdf")
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
With a webhook instead of polling:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
job = pc.submit_job("pptx-to-pdf", "deck.pptx", webhook_url="https://example.com/hooks/privconvert")
|
|
67
|
+
save_somewhere(job.id, job.key)
|
|
68
|
+
# later, when the webhook arrives:
|
|
69
|
+
result = pc.download_job(job)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Verifying webhooks
|
|
73
|
+
|
|
74
|
+
Pass the raw request bytes, not parsed JSON:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
from privconvert import verify_webhook
|
|
78
|
+
|
|
79
|
+
@app.post("/hooks/privconvert")
|
|
80
|
+
def hook():
|
|
81
|
+
job_key = load_job_key(request.json["id"])
|
|
82
|
+
if not verify_webhook(job_key, request.headers.get("X-PrivConvert-Signature"), request.get_data()):
|
|
83
|
+
abort(400)
|
|
84
|
+
...
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Deliveries older than 5 minutes are rejected as replays (`tolerance_s` to change it).
|
|
88
|
+
|
|
89
|
+
## Errors
|
|
90
|
+
|
|
91
|
+
Every error is a `PrivConvertError` with `status`, `code`, `message` and `retry_after`:
|
|
92
|
+
|
|
93
|
+
| Exception | When |
|
|
94
|
+
| --- | --- |
|
|
95
|
+
| `AuthenticationError` | Missing or invalid key (401) |
|
|
96
|
+
| `BadRequestError` | Bad request or unreadable input (400) |
|
|
97
|
+
| `FileTooLargeError` | Above your plan's size limit (413) |
|
|
98
|
+
| `UnsupportedTypeError` | Wrong input type for the tool (415) |
|
|
99
|
+
| `ConversionFailedError` | File read but could not be converted (422) |
|
|
100
|
+
| `RateLimitError` | Per-minute rate or monthly quota exceeded (429) |
|
|
101
|
+
| `ConcurrencyLimitError` | Too many conversions at once (429) |
|
|
102
|
+
| `ServerError` | Problem on our side (5xx) |
|
|
103
|
+
|
|
104
|
+
Short 429 and 503 responses are retried automatically (`max_retries=2`), honouring `Retry-After`. A long `Retry-After`, which means the monthly quota is used up, raises at once instead of sleeping.
|
|
105
|
+
|
|
106
|
+
## Usage and limits
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
pc.me()
|
|
110
|
+
# {'tier': 'growth', 'quota': 50000, 'used': 1234, 'remaining': 48766, 'rpm': 120, ...}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Security notes
|
|
114
|
+
|
|
115
|
+
- The key is read from `PRIVCONVERT_API_KEY` or passed explicitly, and never appears in `repr()` or error messages.
|
|
116
|
+
- Requests go over HTTPS only. A plain `http://` base URL is refused.
|
|
117
|
+
- This package writes nothing to disk except where you call `Result.save()`.
|
|
118
|
+
|
|
119
|
+
## Links
|
|
120
|
+
|
|
121
|
+
- [API documentation](https://privconvert.com/docs/)
|
|
122
|
+
- [Pricing and free trial](https://privconvert.com/developers/)
|
|
123
|
+
- Support: contact@topriv.com
|
|
124
|
+
|
|
125
|
+
MIT License
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.24"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "privconvert"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Official Python client for the PrivConvert file conversion API. Files are processed in memory and never stored."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.10"
|
|
13
|
+
authors = [{ name = "PrivConvert", email = "contact@topriv.com" }]
|
|
14
|
+
keywords = ["privconvert", "file conversion", "pdf", "api", "converter", "privacy"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Software Development :: Libraries",
|
|
21
|
+
"Topic :: Office/Business",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
dependencies = []
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://privconvert.com/developers/"
|
|
28
|
+
Documentation = "https://privconvert.com/docs/"
|
|
29
|
+
Source = "https://github.com/toprivHQ/privconvert-python"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.version]
|
|
32
|
+
path = "src/privconvert/_version.py"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/privconvert"]
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.sdist]
|
|
38
|
+
include = ["src/privconvert", "tests", "README.md", "LICENSE"]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Official Python client for the PrivConvert API.
|
|
2
|
+
|
|
3
|
+
from privconvert import PrivConvert
|
|
4
|
+
|
|
5
|
+
pc = PrivConvert() # reads PRIVCONVERT_API_KEY
|
|
6
|
+
pc.convert("word-to-pdf", "contract.docx").save("contract.pdf")
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from ._client import PrivConvert, Result, Job, JobStatus
|
|
10
|
+
from ._errors import (
|
|
11
|
+
PrivConvertError,
|
|
12
|
+
AuthenticationError,
|
|
13
|
+
BadRequestError,
|
|
14
|
+
FileTooLargeError,
|
|
15
|
+
UnsupportedTypeError,
|
|
16
|
+
ConversionFailedError,
|
|
17
|
+
RateLimitError,
|
|
18
|
+
ConcurrencyLimitError,
|
|
19
|
+
ServerError,
|
|
20
|
+
JobExpiredError,
|
|
21
|
+
JobNotFoundError,
|
|
22
|
+
)
|
|
23
|
+
from ._webhooks import verify_webhook, WebhookVerificationError
|
|
24
|
+
from ._version import __version__
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"PrivConvert",
|
|
28
|
+
"Result",
|
|
29
|
+
"Job",
|
|
30
|
+
"JobStatus",
|
|
31
|
+
"PrivConvertError",
|
|
32
|
+
"AuthenticationError",
|
|
33
|
+
"BadRequestError",
|
|
34
|
+
"FileTooLargeError",
|
|
35
|
+
"UnsupportedTypeError",
|
|
36
|
+
"ConversionFailedError",
|
|
37
|
+
"RateLimitError",
|
|
38
|
+
"ConcurrencyLimitError",
|
|
39
|
+
"ServerError",
|
|
40
|
+
"JobExpiredError",
|
|
41
|
+
"JobNotFoundError",
|
|
42
|
+
"verify_webhook",
|
|
43
|
+
"WebhookVerificationError",
|
|
44
|
+
"__version__",
|
|
45
|
+
]
|
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import secrets
|
|
7
|
+
import time
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import IO, Any, Dict, Iterable, List, Mapping, Optional, Tuple, Union
|
|
13
|
+
|
|
14
|
+
from ._errors import PrivConvertError, error_for
|
|
15
|
+
from ._version import __version__
|
|
16
|
+
|
|
17
|
+
DEFAULT_BASE_URL = "https://privconvert.com/api"
|
|
18
|
+
|
|
19
|
+
# A single POST accepts up to 100 MB; above this the file goes through the
|
|
20
|
+
# chunked upload flow. Parts stay well below the 95 MB per-part ceiling so a
|
|
21
|
+
# slow link never holds one request open long enough to time out at the edge.
|
|
22
|
+
DIRECT_LIMIT = 90 * 1024 * 1024
|
|
23
|
+
CHUNK_SIZE = 20 * 1024 * 1024
|
|
24
|
+
|
|
25
|
+
# A Retry-After longer than this means the monthly quota is used up, not a
|
|
26
|
+
# momentary limit, so the call fails instead of sleeping.
|
|
27
|
+
MAX_RETRY_WAIT_S = 60.0
|
|
28
|
+
|
|
29
|
+
_TOOL_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
|
|
30
|
+
_JOB_ID_RE = re.compile(r"^[A-Za-z0-9_-]{1,128}$")
|
|
31
|
+
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
32
|
+
_STATUS_BY_CODE = {
|
|
33
|
+
"bad_request": 400,
|
|
34
|
+
"unauthorized": 401,
|
|
35
|
+
"file_too_large": 413,
|
|
36
|
+
"unsupported_type": 415,
|
|
37
|
+
"conversion_failed": 422,
|
|
38
|
+
"rate_limited": 429,
|
|
39
|
+
"concurrency_limited": 429,
|
|
40
|
+
"server_error": 500,
|
|
41
|
+
"server_busy": 503,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
FileInput = Union[str, "os.PathLike[str]", bytes, bytearray, memoryview, IO[bytes]]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Result:
|
|
49
|
+
"""A converted file, held in memory."""
|
|
50
|
+
|
|
51
|
+
content: bytes
|
|
52
|
+
filename: Optional[str]
|
|
53
|
+
content_type: Optional[str]
|
|
54
|
+
headers: Dict[str, str] = field(repr=False, default_factory=dict)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def credits_remaining(self) -> Optional[int]:
|
|
58
|
+
v = self.headers.get("x-credits-remaining")
|
|
59
|
+
return int(v) if v and v.isdigit() else None
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def converted_count(self) -> Optional[int]:
|
|
63
|
+
v = self.headers.get("x-converted-count")
|
|
64
|
+
return int(v) if v and v.isdigit() else None
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def failed_count(self) -> int:
|
|
68
|
+
v = self.headers.get("x-failed-count")
|
|
69
|
+
return int(v) if v and v.isdigit() else 0
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def partial(self) -> bool:
|
|
73
|
+
return "x-partial-success" in self.headers
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def sanitize_report(self) -> Optional[Dict[str, Any]]:
|
|
77
|
+
v = self.headers.get("x-sanitize-report")
|
|
78
|
+
if not v:
|
|
79
|
+
return None
|
|
80
|
+
try:
|
|
81
|
+
return json.loads(v)
|
|
82
|
+
except ValueError:
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
def save(self, path: Union[str, "os.PathLike[str]"]) -> str:
|
|
86
|
+
"""Write the result to ``path`` and return the path."""
|
|
87
|
+
p = os.fspath(path)
|
|
88
|
+
with open(p, "wb") as f:
|
|
89
|
+
f.write(self.content)
|
|
90
|
+
return p
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class Job:
|
|
95
|
+
"""A submitted async job. ``key`` is returned exactly once: store it."""
|
|
96
|
+
|
|
97
|
+
id: str
|
|
98
|
+
key: str = field(repr=False)
|
|
99
|
+
status: str = "processing"
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class JobStatus:
|
|
104
|
+
id: str
|
|
105
|
+
status: str
|
|
106
|
+
raw: Dict[str, Any] = field(repr=False)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def done(self) -> bool:
|
|
110
|
+
return self.status in ("finished", "failed")
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def error(self) -> Optional[Dict[str, Any]]:
|
|
114
|
+
e = self.raw.get("error")
|
|
115
|
+
return e if isinstance(e, dict) else None
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def expires_at(self) -> Optional[int]:
|
|
119
|
+
return self.raw.get("expires_at")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class PrivConvert:
|
|
123
|
+
"""Client for the PrivConvert API.
|
|
124
|
+
|
|
125
|
+
The key is read from ``api_key`` or the ``PRIVCONVERT_API_KEY`` environment
|
|
126
|
+
variable. Files are read into memory and never written to disk by this
|
|
127
|
+
package; only ``Result.save`` writes, and only where you tell it to.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def __init__(
|
|
131
|
+
self,
|
|
132
|
+
api_key: Optional[str] = None,
|
|
133
|
+
*,
|
|
134
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
135
|
+
timeout: float = 300.0,
|
|
136
|
+
max_retries: int = 2,
|
|
137
|
+
) -> None:
|
|
138
|
+
key = api_key if api_key is not None else os.environ.get("PRIVCONVERT_API_KEY", "")
|
|
139
|
+
key = (key or "").strip()
|
|
140
|
+
if not key:
|
|
141
|
+
raise PrivConvertError("No API key. Pass api_key=... or set PRIVCONVERT_API_KEY.")
|
|
142
|
+
if any(c.isspace() for c in key) or not key.isprintable():
|
|
143
|
+
raise PrivConvertError("The API key contains invalid characters.")
|
|
144
|
+
self._key = key
|
|
145
|
+
self.base_url = _check_base_url(base_url)
|
|
146
|
+
self.timeout = float(timeout)
|
|
147
|
+
self.max_retries = max(0, int(max_retries))
|
|
148
|
+
|
|
149
|
+
def __repr__(self) -> str:
|
|
150
|
+
return f"PrivConvert(base_url={self.base_url!r})"
|
|
151
|
+
|
|
152
|
+
# ── conversions ──────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
def convert(
|
|
155
|
+
self,
|
|
156
|
+
tool: str,
|
|
157
|
+
file: FileInput,
|
|
158
|
+
*,
|
|
159
|
+
filename: Optional[str] = None,
|
|
160
|
+
**params: Any,
|
|
161
|
+
) -> Result:
|
|
162
|
+
"""Convert one file. Files above 90 MB are uploaded in parts automatically.
|
|
163
|
+
|
|
164
|
+
Extra keyword arguments are sent as form fields, e.g. ``quality=80``.
|
|
165
|
+
"""
|
|
166
|
+
tool = _check_tool(tool)
|
|
167
|
+
name, data = _read_input(file, filename)
|
|
168
|
+
if len(data) > DIRECT_LIMIT:
|
|
169
|
+
return self._convert_chunked(tool, name, data, params)
|
|
170
|
+
fields = _form_params(params)
|
|
171
|
+
return self._request_file("POST", f"/convert/{tool}", fields=fields, files=[("file", name, data)])
|
|
172
|
+
|
|
173
|
+
def convert_batch(
|
|
174
|
+
self,
|
|
175
|
+
tool: str,
|
|
176
|
+
files: Iterable[FileInput],
|
|
177
|
+
**params: Any,
|
|
178
|
+
) -> Result:
|
|
179
|
+
"""Run one tool over up to 20 files; the result is a ZIP.
|
|
180
|
+
|
|
181
|
+
Check ``result.failed_count`` / ``result.partial``: a batch where some
|
|
182
|
+
files failed still succeeds with the files that converted.
|
|
183
|
+
"""
|
|
184
|
+
tool = _check_tool(tool)
|
|
185
|
+
parts = []
|
|
186
|
+
for i, f in enumerate(files):
|
|
187
|
+
name, data = _read_input(f, None)
|
|
188
|
+
if name == "file":
|
|
189
|
+
name = f"file{i + 1}"
|
|
190
|
+
parts.append(("files", name, data))
|
|
191
|
+
if not parts:
|
|
192
|
+
raise PrivConvertError("convert_batch needs at least one file.")
|
|
193
|
+
if len(parts) > 20:
|
|
194
|
+
raise PrivConvertError("convert_batch takes at most 20 files per request.")
|
|
195
|
+
fields = [("tool", tool)] + _form_params(params)
|
|
196
|
+
return self._request_file("POST", "/convert-batch", fields=fields, files=parts)
|
|
197
|
+
|
|
198
|
+
# ── async jobs ───────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
def submit_job(
|
|
201
|
+
self,
|
|
202
|
+
tool: str,
|
|
203
|
+
file: FileInput,
|
|
204
|
+
*,
|
|
205
|
+
filename: Optional[str] = None,
|
|
206
|
+
webhook_url: Optional[str] = None,
|
|
207
|
+
**params: Any,
|
|
208
|
+
) -> Job:
|
|
209
|
+
"""Submit an async job. Returns at once with the job id and one-time key."""
|
|
210
|
+
tool = _check_tool(tool)
|
|
211
|
+
name, data = _read_input(file, filename)
|
|
212
|
+
fields = _form_params(params)
|
|
213
|
+
if webhook_url:
|
|
214
|
+
if not str(webhook_url).startswith("https://"):
|
|
215
|
+
raise PrivConvertError("webhook_url must be an https:// URL.")
|
|
216
|
+
fields.append(("webhook_url", str(webhook_url)))
|
|
217
|
+
body = self._request_json("POST", f"/jobs/{tool}", fields=fields, files=[("file", name, data)])
|
|
218
|
+
if not isinstance(body, dict) or "id" not in body or "key" not in body:
|
|
219
|
+
raise PrivConvertError("Unexpected response when submitting the job.")
|
|
220
|
+
return Job(id=str(body["id"]), key=str(body["key"]), status=str(body.get("status", "processing")))
|
|
221
|
+
|
|
222
|
+
def get_job(self, job_id: Union[str, Job]) -> JobStatus:
|
|
223
|
+
jid = _check_job_id(job_id)
|
|
224
|
+
body = self._request_json("GET", f"/jobs/{jid}", job=True)
|
|
225
|
+
return JobStatus(id=jid, status=str(body.get("status", "")), raw=body)
|
|
226
|
+
|
|
227
|
+
def download_job(self, job: Job) -> Result:
|
|
228
|
+
jid = _check_job_id(job)
|
|
229
|
+
return self._request_file(
|
|
230
|
+
"GET", f"/jobs/{jid}/download", extra_headers={"X-Job-Key": job.key}, job=True
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
def delete_job(self, job_id: Union[str, Job]) -> None:
|
|
234
|
+
jid = _check_job_id(job_id)
|
|
235
|
+
self._request("DELETE", f"/jobs/{jid}", job=True)
|
|
236
|
+
|
|
237
|
+
def wait(
|
|
238
|
+
self,
|
|
239
|
+
job: Job,
|
|
240
|
+
*,
|
|
241
|
+
poll_interval: float = 2.0,
|
|
242
|
+
timeout: float = 1800.0,
|
|
243
|
+
delete_after: bool = True,
|
|
244
|
+
) -> Result:
|
|
245
|
+
"""Poll until the job finishes, then download the result.
|
|
246
|
+
|
|
247
|
+
Raises the same error a synchronous call would have raised if the job
|
|
248
|
+
failed. With ``delete_after`` the stored result is wiped right after
|
|
249
|
+
download instead of waiting out the retention window.
|
|
250
|
+
"""
|
|
251
|
+
deadline = time.monotonic() + timeout
|
|
252
|
+
interval = max(0.5, poll_interval)
|
|
253
|
+
while True:
|
|
254
|
+
st = self.get_job(job)
|
|
255
|
+
if st.status == "finished":
|
|
256
|
+
break
|
|
257
|
+
if st.status == "failed":
|
|
258
|
+
e = st.error or {}
|
|
259
|
+
code = e.get("code")
|
|
260
|
+
raise error_for(
|
|
261
|
+
_STATUS_BY_CODE.get(str(code), 422),
|
|
262
|
+
str(e.get("message") or "The conversion failed."),
|
|
263
|
+
code,
|
|
264
|
+
None,
|
|
265
|
+
)
|
|
266
|
+
if time.monotonic() + interval > deadline:
|
|
267
|
+
raise PrivConvertError(f"Job {job.id} did not finish within {timeout:.0f}s.")
|
|
268
|
+
time.sleep(interval)
|
|
269
|
+
interval = min(interval * 1.5, 10.0)
|
|
270
|
+
result = self.download_job(job)
|
|
271
|
+
if delete_after:
|
|
272
|
+
try:
|
|
273
|
+
self.delete_job(job)
|
|
274
|
+
except PrivConvertError:
|
|
275
|
+
pass
|
|
276
|
+
return result
|
|
277
|
+
|
|
278
|
+
# ── account & catalog ────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
def me(self) -> Dict[str, Any]:
|
|
281
|
+
"""Your plan, monthly quota and current usage."""
|
|
282
|
+
return self._request_json("GET", "/me")
|
|
283
|
+
|
|
284
|
+
def tools(self, category: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
285
|
+
"""The public catalog of every tool, optionally filtered by category."""
|
|
286
|
+
path = "/tools"
|
|
287
|
+
if category:
|
|
288
|
+
path += "?" + urllib.parse.urlencode({"category": category})
|
|
289
|
+
body = self._request_json("GET", path, auth=False)
|
|
290
|
+
return list(body.get("tools", [])) if isinstance(body, dict) else []
|
|
291
|
+
|
|
292
|
+
# ── internals ────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
def _convert_chunked(self, tool: str, name: str, data: bytes, params: Mapping[str, Any]) -> Result:
|
|
295
|
+
chunks = [data[i : i + CHUNK_SIZE] for i in range(0, len(data), CHUNK_SIZE)]
|
|
296
|
+
init = self._request_json(
|
|
297
|
+
"POST",
|
|
298
|
+
"/upload-init",
|
|
299
|
+
fields=[
|
|
300
|
+
("total_size", str(len(data))),
|
|
301
|
+
("total_chunks", str(len(chunks))),
|
|
302
|
+
("filename", name),
|
|
303
|
+
("tool", tool),
|
|
304
|
+
],
|
|
305
|
+
)
|
|
306
|
+
upload_id = str(init.get("upload_id", ""))
|
|
307
|
+
if not upload_id:
|
|
308
|
+
raise PrivConvertError("The server did not return an upload id.")
|
|
309
|
+
for i, chunk in enumerate(chunks):
|
|
310
|
+
self._request_json(
|
|
311
|
+
"POST",
|
|
312
|
+
"/upload-chunk",
|
|
313
|
+
fields=[("upload_id", upload_id), ("chunk_index", str(i))],
|
|
314
|
+
files=[("file", "chunk", chunk)],
|
|
315
|
+
)
|
|
316
|
+
fields = [("upload_id", upload_id)] + _form_params(params)
|
|
317
|
+
return self._request_file("POST", "/convert-chunked", fields=fields)
|
|
318
|
+
|
|
319
|
+
def _request_file(self, method: str, path: str, **kw: Any) -> Result:
|
|
320
|
+
status, headers, body = self._request(method, path, **kw)
|
|
321
|
+
return Result(
|
|
322
|
+
content=body,
|
|
323
|
+
filename=_filename_from(headers.get("content-disposition", "")),
|
|
324
|
+
content_type=headers.get("content-type"),
|
|
325
|
+
headers=headers,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
def _request_json(self, method: str, path: str, **kw: Any) -> Dict[str, Any]:
|
|
329
|
+
status, headers, body = self._request(method, path, **kw)
|
|
330
|
+
try:
|
|
331
|
+
out = json.loads(body.decode("utf-8")) if body else {}
|
|
332
|
+
except ValueError:
|
|
333
|
+
raise PrivConvertError("The server returned a response that is not JSON.", status=status)
|
|
334
|
+
return out if isinstance(out, dict) else {"data": out}
|
|
335
|
+
|
|
336
|
+
def _request(
|
|
337
|
+
self,
|
|
338
|
+
method: str,
|
|
339
|
+
path: str,
|
|
340
|
+
*,
|
|
341
|
+
fields: Optional[List[Tuple[str, str]]] = None,
|
|
342
|
+
files: Optional[List[Tuple[str, str, bytes]]] = None,
|
|
343
|
+
extra_headers: Optional[Mapping[str, str]] = None,
|
|
344
|
+
auth: bool = True,
|
|
345
|
+
job: bool = False,
|
|
346
|
+
) -> Tuple[int, Dict[str, str], bytes]:
|
|
347
|
+
url = self.base_url + path
|
|
348
|
+
headers = {"User-Agent": f"privconvert-python/{__version__}", "Accept": "*/*"}
|
|
349
|
+
if auth:
|
|
350
|
+
headers["Authorization"] = f"Bearer {self._key}"
|
|
351
|
+
if extra_headers:
|
|
352
|
+
headers.update(extra_headers)
|
|
353
|
+
body: Optional[bytes] = None
|
|
354
|
+
if fields is not None or files is not None:
|
|
355
|
+
body, ctype = _multipart(fields or [], files or [])
|
|
356
|
+
headers["Content-Type"] = ctype
|
|
357
|
+
elif method in ("POST", "PUT"):
|
|
358
|
+
body = b""
|
|
359
|
+
|
|
360
|
+
attempt = 0
|
|
361
|
+
while True:
|
|
362
|
+
req = urllib.request.Request(url, data=body, method=method, headers=headers)
|
|
363
|
+
try:
|
|
364
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
365
|
+
return resp.status, _lower(resp.headers.items()), resp.read()
|
|
366
|
+
except urllib.error.HTTPError as e:
|
|
367
|
+
status = e.code
|
|
368
|
+
h = _lower(e.headers.items()) if e.headers else {}
|
|
369
|
+
raw = e.read() if hasattr(e, "read") else b""
|
|
370
|
+
message, code = _parse_error(raw, status)
|
|
371
|
+
retry_after = _retry_after(h.get("retry-after"))
|
|
372
|
+
retryable = status in (429, 502, 503, 504)
|
|
373
|
+
wait = retry_after if retry_after is not None else min(2.0 * (2 ** attempt), 20.0)
|
|
374
|
+
if retryable and attempt < self.max_retries and wait <= MAX_RETRY_WAIT_S:
|
|
375
|
+
attempt += 1
|
|
376
|
+
time.sleep(wait)
|
|
377
|
+
continue
|
|
378
|
+
raise error_for(status, message, code, retry_after, job=job) from None
|
|
379
|
+
except urllib.error.URLError as e:
|
|
380
|
+
raise PrivConvertError(f"Could not reach PrivConvert: {e.reason}") from None
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _check_base_url(url: str) -> str:
|
|
384
|
+
u = urllib.parse.urlsplit(str(url).rstrip("/"))
|
|
385
|
+
if u.scheme == "https" and u.hostname:
|
|
386
|
+
return urllib.parse.urlunsplit(u)
|
|
387
|
+
if u.scheme == "http" and u.hostname in _LOCAL_HOSTS:
|
|
388
|
+
return urllib.parse.urlunsplit(u)
|
|
389
|
+
raise PrivConvertError("base_url must use https:// so the API key is never sent in clear text.")
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _check_tool(tool: str) -> str:
|
|
393
|
+
t = str(tool or "").strip().lower()
|
|
394
|
+
if not _TOOL_RE.match(t):
|
|
395
|
+
raise PrivConvertError(f"Invalid tool id: {tool!r}")
|
|
396
|
+
return t
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _check_job_id(job: Union[str, Job]) -> str:
|
|
400
|
+
jid = job.id if isinstance(job, Job) else str(job or "")
|
|
401
|
+
if not _JOB_ID_RE.match(jid):
|
|
402
|
+
raise PrivConvertError("Invalid job id.")
|
|
403
|
+
return jid
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _read_input(file: FileInput, filename: Optional[str]) -> Tuple[str, bytes]:
|
|
407
|
+
if isinstance(file, (bytes, bytearray, memoryview)):
|
|
408
|
+
return (filename or "file"), bytes(file)
|
|
409
|
+
if isinstance(file, (str, os.PathLike)):
|
|
410
|
+
p = os.fspath(file)
|
|
411
|
+
with open(p, "rb") as f:
|
|
412
|
+
data = f.read()
|
|
413
|
+
return (filename or os.path.basename(p) or "file"), data
|
|
414
|
+
if hasattr(file, "read"):
|
|
415
|
+
data = file.read()
|
|
416
|
+
if isinstance(data, str):
|
|
417
|
+
raise PrivConvertError("Open the file in binary mode ('rb').")
|
|
418
|
+
name = filename or os.path.basename(str(getattr(file, "name", "") or "")) or "file"
|
|
419
|
+
return name, bytes(data)
|
|
420
|
+
raise PrivConvertError("file must be a path, bytes, or a binary file object.")
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _form_params(params: Mapping[str, Any]) -> List[Tuple[str, str]]:
|
|
424
|
+
out: List[Tuple[str, str]] = []
|
|
425
|
+
for k, v in params.items():
|
|
426
|
+
if v is None:
|
|
427
|
+
continue
|
|
428
|
+
if isinstance(v, bool):
|
|
429
|
+
v = "true" if v else "false"
|
|
430
|
+
elif isinstance(v, (list, dict)):
|
|
431
|
+
v = json.dumps(v)
|
|
432
|
+
out.append((str(k), str(v)))
|
|
433
|
+
return out
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _safe_header_value(s: str) -> str:
|
|
437
|
+
return re.sub(r'[\r\n"\\]', "_", s)[:255] or "file"
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _multipart(fields: List[Tuple[str, str]], files: List[Tuple[str, str, bytes]]) -> Tuple[bytes, str]:
|
|
441
|
+
boundary = "----privconvert" + secrets.token_hex(16)
|
|
442
|
+
parts: List[bytes] = []
|
|
443
|
+
for name, value in fields:
|
|
444
|
+
parts.append(
|
|
445
|
+
f'--{boundary}\r\nContent-Disposition: form-data; name="{_safe_header_value(name)}"\r\n\r\n'.encode()
|
|
446
|
+
+ value.encode("utf-8")
|
|
447
|
+
+ b"\r\n"
|
|
448
|
+
)
|
|
449
|
+
for name, fname, data in files:
|
|
450
|
+
parts.append(
|
|
451
|
+
(
|
|
452
|
+
f"--{boundary}\r\n"
|
|
453
|
+
f'Content-Disposition: form-data; name="{_safe_header_value(name)}"; filename="{_safe_header_value(fname)}"\r\n'
|
|
454
|
+
"Content-Type: application/octet-stream\r\n\r\n"
|
|
455
|
+
).encode("utf-8")
|
|
456
|
+
+ data
|
|
457
|
+
+ b"\r\n"
|
|
458
|
+
)
|
|
459
|
+
parts.append(f"--{boundary}--\r\n".encode())
|
|
460
|
+
return b"".join(parts), f"multipart/form-data; boundary={boundary}"
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _lower(items: Iterable[Tuple[str, str]]) -> Dict[str, str]:
|
|
464
|
+
return {k.lower(): v for k, v in items}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _parse_error(raw: bytes, status: int) -> Tuple[str, Optional[str]]:
|
|
468
|
+
try:
|
|
469
|
+
body = json.loads(raw.decode("utf-8"))
|
|
470
|
+
except (ValueError, UnicodeDecodeError):
|
|
471
|
+
return f"HTTP {status}", None
|
|
472
|
+
if isinstance(body, dict):
|
|
473
|
+
err = body.get("error")
|
|
474
|
+
if isinstance(err, dict):
|
|
475
|
+
return str(err.get("message") or f"HTTP {status}"), err.get("code")
|
|
476
|
+
detail = body.get("detail")
|
|
477
|
+
if isinstance(detail, str):
|
|
478
|
+
return detail, body.get("code")
|
|
479
|
+
if isinstance(detail, dict):
|
|
480
|
+
return str(detail.get("message") or f"HTTP {status}"), detail.get("code")
|
|
481
|
+
return f"HTTP {status}", None
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _retry_after(v: Optional[str]) -> Optional[float]:
|
|
485
|
+
if not v:
|
|
486
|
+
return None
|
|
487
|
+
try:
|
|
488
|
+
return max(0.0, float(v))
|
|
489
|
+
except ValueError:
|
|
490
|
+
return None
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _filename_from(cd: str) -> Optional[str]:
|
|
494
|
+
m = re.search(r"filename\*=UTF-8''([^;]+)", cd, re.I)
|
|
495
|
+
if m:
|
|
496
|
+
return os.path.basename(urllib.parse.unquote(m.group(1)))
|
|
497
|
+
m = re.search(r'filename="?([^";]+)"?', cd, re.I)
|
|
498
|
+
return os.path.basename(m.group(1)) if m else None
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PrivConvertError(Exception):
|
|
7
|
+
"""Base class for every error raised by this package."""
|
|
8
|
+
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
message: str,
|
|
12
|
+
*,
|
|
13
|
+
status: Optional[int] = None,
|
|
14
|
+
code: Optional[str] = None,
|
|
15
|
+
retry_after: Optional[float] = None,
|
|
16
|
+
) -> None:
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.message = message
|
|
19
|
+
self.status = status
|
|
20
|
+
self.code = code
|
|
21
|
+
self.retry_after = retry_after
|
|
22
|
+
|
|
23
|
+
def __repr__(self) -> str:
|
|
24
|
+
return f"{type(self).__name__}(status={self.status!r}, code={self.code!r}, message={self.message!r})"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AuthenticationError(PrivConvertError):
|
|
28
|
+
"""401: missing, invalid or revoked API key."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BadRequestError(PrivConvertError):
|
|
32
|
+
"""400: malformed request, unknown tool, bad parameter or unreadable input."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class FileTooLargeError(PrivConvertError):
|
|
36
|
+
"""413: the file is above your plan's size limit."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class UnsupportedTypeError(PrivConvertError):
|
|
40
|
+
"""415: the input file type is not valid for this tool."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ConversionFailedError(PrivConvertError):
|
|
44
|
+
"""422: the file was read but could not be converted."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class RateLimitError(PrivConvertError):
|
|
48
|
+
"""429 rate_limited: per-minute rate or monthly quota exceeded."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ConcurrencyLimitError(RateLimitError):
|
|
52
|
+
"""429 concurrency_limited: too many conversions running at once."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class ServerError(PrivConvertError):
|
|
56
|
+
"""5xx: a problem on the server side. Safe to retry."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class JobNotFoundError(PrivConvertError):
|
|
60
|
+
"""404 for a job: deleted, never existed, or lost in a service restart."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class JobExpiredError(PrivConvertError):
|
|
64
|
+
"""410: the job result passed its retention window and was deleted."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
_BY_STATUS = {
|
|
68
|
+
400: BadRequestError,
|
|
69
|
+
401: AuthenticationError,
|
|
70
|
+
403: AuthenticationError,
|
|
71
|
+
413: FileTooLargeError,
|
|
72
|
+
415: UnsupportedTypeError,
|
|
73
|
+
422: ConversionFailedError,
|
|
74
|
+
410: JobExpiredError,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def error_for(
|
|
79
|
+
status: int,
|
|
80
|
+
message: str,
|
|
81
|
+
code: Optional[str],
|
|
82
|
+
retry_after: Optional[float],
|
|
83
|
+
*,
|
|
84
|
+
job: bool = False,
|
|
85
|
+
) -> PrivConvertError:
|
|
86
|
+
if status == 429:
|
|
87
|
+
cls = ConcurrencyLimitError if code == "concurrency_limited" else RateLimitError
|
|
88
|
+
elif status == 404 and job:
|
|
89
|
+
cls = JobNotFoundError
|
|
90
|
+
elif status >= 500:
|
|
91
|
+
cls = ServerError
|
|
92
|
+
else:
|
|
93
|
+
cls = _BY_STATUS.get(status, PrivConvertError)
|
|
94
|
+
return cls(message, status=status, code=code, retry_after=retry_after)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import hmac
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from typing import Optional, Union
|
|
8
|
+
|
|
9
|
+
_SIG_RE = re.compile(r"^t=(\d{1,12}),v1=([0-9a-f]{64})$")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WebhookVerificationError(Exception):
|
|
13
|
+
"""Raised by ``verify_webhook(..., raise_on_error=True)``."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def verify_webhook(
|
|
17
|
+
job_key: str,
|
|
18
|
+
signature: Optional[str],
|
|
19
|
+
raw_body: Union[bytes, bytearray, memoryview],
|
|
20
|
+
*,
|
|
21
|
+
tolerance_s: int = 300,
|
|
22
|
+
now: Optional[float] = None,
|
|
23
|
+
raise_on_error: bool = False,
|
|
24
|
+
) -> bool:
|
|
25
|
+
"""Check an ``X-PrivConvert-Signature`` header against the raw request body.
|
|
26
|
+
|
|
27
|
+
``raw_body`` must be the exact bytes received, not re-serialized JSON.
|
|
28
|
+
``job_key`` is the one-time ``key`` returned when the job was submitted.
|
|
29
|
+
Deliveries older than ``tolerance_s`` seconds are rejected as replays.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def fail(reason: str) -> bool:
|
|
33
|
+
if raise_on_error:
|
|
34
|
+
raise WebhookVerificationError(reason)
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
if not job_key or not isinstance(job_key, str):
|
|
38
|
+
return fail("missing job key")
|
|
39
|
+
if not signature or not isinstance(signature, str):
|
|
40
|
+
return fail("missing signature header")
|
|
41
|
+
if isinstance(raw_body, str):
|
|
42
|
+
return fail("raw_body must be bytes, not str")
|
|
43
|
+
m = _SIG_RE.match(signature.strip())
|
|
44
|
+
if not m:
|
|
45
|
+
return fail("malformed signature header")
|
|
46
|
+
ts = int(m.group(1))
|
|
47
|
+
current = time.time() if now is None else now
|
|
48
|
+
if abs(current - ts) > tolerance_s:
|
|
49
|
+
return fail("timestamp outside tolerance")
|
|
50
|
+
sig_key = hashlib.sha256(b"pc-webhook-sig-v1|" + job_key.encode("utf-8")).digest()
|
|
51
|
+
expected = hmac.new(sig_key, f"{ts}.".encode("ascii") + bytes(raw_body), hashlib.sha256).hexdigest()
|
|
52
|
+
if not hmac.compare_digest(expected, m.group(2)):
|
|
53
|
+
return fail("signature mismatch")
|
|
54
|
+
return True
|
|
File without changes
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Live checks against the real API. Needs PRIVCONVERT_API_KEY; spends a few credits.
|
|
2
|
+
|
|
3
|
+
PRIVCONVERT_API_KEY=pk_live_... python tests/live_check.py [--large]
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import io
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import zipfile
|
|
10
|
+
|
|
11
|
+
import privconvert
|
|
12
|
+
from privconvert import PrivConvert
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main() -> int:
|
|
16
|
+
pc = PrivConvert()
|
|
17
|
+
ok = True
|
|
18
|
+
|
|
19
|
+
def check(name, cond, extra=""):
|
|
20
|
+
nonlocal ok
|
|
21
|
+
ok = ok and bool(cond)
|
|
22
|
+
print(("PASS " if cond else "FAIL ") + name + (f" {extra}" if extra else ""))
|
|
23
|
+
|
|
24
|
+
me = pc.me()
|
|
25
|
+
check("me", "remaining" in me and "tier" in me, f"tier={me.get('tier')} remaining={me.get('remaining')}")
|
|
26
|
+
|
|
27
|
+
tools = pc.tools(category="data")
|
|
28
|
+
check("tools", any(t.get("id") == "json-to-csv" for t in tools), f"{len(tools)} data tools")
|
|
29
|
+
|
|
30
|
+
r = pc.convert("json-to-csv", b'[{"a":1,"b":2},{"a":3,"b":4}]', filename="t.json")
|
|
31
|
+
check("convert", r.content.replace(b"\r", b"").startswith(b"a,b\n1,2"), f"file={r.filename} left={r.credits_remaining}")
|
|
32
|
+
|
|
33
|
+
b = pc.convert_batch("json-to-csv", [io.BytesIO(b'[{"x":1}]'), b'[{"y":2}]', b"not json"])
|
|
34
|
+
names = zipfile.ZipFile(io.BytesIO(b.content)).namelist()
|
|
35
|
+
check("batch partial", b.partial and b.failed_count == 1 and b.converted_count == 2, f"{names}")
|
|
36
|
+
|
|
37
|
+
job = pc.submit_job("json-to-csv", b'[{"j":9}]', filename="job.json")
|
|
38
|
+
res = pc.wait(job, poll_interval=1)
|
|
39
|
+
check("async job", res.content.replace(b"\r", b"").startswith(b"j\n9"), f"id={job.id}")
|
|
40
|
+
try:
|
|
41
|
+
pc.get_job(job)
|
|
42
|
+
check("job deleted after wait", False)
|
|
43
|
+
except privconvert.JobNotFoundError:
|
|
44
|
+
check("job deleted after wait", True)
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
pc.convert("json-to-csv", b"this is not json at all {")
|
|
48
|
+
check("error mapping", False)
|
|
49
|
+
except privconvert.PrivConvertError as e:
|
|
50
|
+
check("error mapping", e.status in (400, 422), f"{type(e).__name__} {e.status} {e.message[:60]}")
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
PrivConvert(api_key="pk_live_" + "x" * 40).me()
|
|
54
|
+
check("bad key", False)
|
|
55
|
+
except privconvert.AuthenticationError:
|
|
56
|
+
check("bad key", True)
|
|
57
|
+
|
|
58
|
+
if "--large" in sys.argv:
|
|
59
|
+
buf = io.BytesIO()
|
|
60
|
+
with zipfile.ZipFile(buf, "w", zipfile.ZIP_STORED) as z:
|
|
61
|
+
z.writestr("big.bin", os.urandom(95 * 1024 * 1024))
|
|
62
|
+
data = buf.getvalue()
|
|
63
|
+
r = pc.convert("zip-to-targz", data, filename="big.zip")
|
|
64
|
+
check("chunked upload", r.content[:2] == b"\x1f\x8b" and len(r.content) > 90 * 1024 * 1024,
|
|
65
|
+
f"in={len(data)>>20}MB out={len(r.content)>>20}MB")
|
|
66
|
+
|
|
67
|
+
print("ALL PASS" if ok else "SOME FAILED")
|
|
68
|
+
return 0 if ok else 1
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
sys.exit(main())
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Offline tests: no network, no API key needed."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
import unittest
|
|
6
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
7
|
+
|
|
8
|
+
import privconvert
|
|
9
|
+
from privconvert import PrivConvert, verify_webhook
|
|
10
|
+
from privconvert._client import _multipart, _filename_from
|
|
11
|
+
|
|
12
|
+
VECTOR_KEY = "pjk_testvector_do_not_use_0000000000000000000"
|
|
13
|
+
VECTOR_TS = 1760000000
|
|
14
|
+
VECTOR_BODY = (
|
|
15
|
+
b'{"event":"job.finished","event_id":"evt_00000000000000000000000000000000",'
|
|
16
|
+
b'"id":"job_000000000000000000000000","status":"finished","tool":"txt-to-pdf"}'
|
|
17
|
+
)
|
|
18
|
+
VECTOR_SIG = "t=1760000000,v1=609fa0d5493200864ae983137780d6fa6cfd7946953052fcaf4f6d4b8792803f"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class WebhookTests(unittest.TestCase):
|
|
22
|
+
def test_published_vector(self):
|
|
23
|
+
self.assertTrue(verify_webhook(VECTOR_KEY, VECTOR_SIG, VECTOR_BODY, now=VECTOR_TS))
|
|
24
|
+
|
|
25
|
+
def test_tampered_body(self):
|
|
26
|
+
self.assertFalse(verify_webhook(VECTOR_KEY, VECTOR_SIG, VECTOR_BODY + b" ", now=VECTOR_TS))
|
|
27
|
+
|
|
28
|
+
def test_wrong_key(self):
|
|
29
|
+
self.assertFalse(verify_webhook(VECTOR_KEY + "x", VECTOR_SIG, VECTOR_BODY, now=VECTOR_TS))
|
|
30
|
+
|
|
31
|
+
def test_stale_timestamp(self):
|
|
32
|
+
self.assertFalse(verify_webhook(VECTOR_KEY, VECTOR_SIG, VECTOR_BODY, now=VECTOR_TS + 301))
|
|
33
|
+
|
|
34
|
+
def test_malformed(self):
|
|
35
|
+
for sig in ("", "t=1,v1=zz", "v1=abc", None, "t=1760000000,v1=" + "0" * 63):
|
|
36
|
+
self.assertFalse(verify_webhook(VECTOR_KEY, sig, VECTOR_BODY, now=VECTOR_TS))
|
|
37
|
+
|
|
38
|
+
def test_str_body_rejected(self):
|
|
39
|
+
self.assertFalse(verify_webhook(VECTOR_KEY, VECTOR_SIG, VECTOR_BODY.decode(), now=VECTOR_TS))
|
|
40
|
+
|
|
41
|
+
def test_raise_on_error(self):
|
|
42
|
+
with self.assertRaises(privconvert.WebhookVerificationError):
|
|
43
|
+
verify_webhook(VECTOR_KEY, "bad", VECTOR_BODY, raise_on_error=True)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ClientValidationTests(unittest.TestCase):
|
|
47
|
+
def test_requires_key(self):
|
|
48
|
+
with self.assertRaises(privconvert.PrivConvertError):
|
|
49
|
+
PrivConvert(api_key="")
|
|
50
|
+
|
|
51
|
+
def test_refuses_plain_http(self):
|
|
52
|
+
with self.assertRaises(privconvert.PrivConvertError):
|
|
53
|
+
PrivConvert(api_key="pk_live_x", base_url="http://example.com/api")
|
|
54
|
+
|
|
55
|
+
def test_allows_local_http(self):
|
|
56
|
+
PrivConvert(api_key="pk_live_x", base_url="http://127.0.0.1:9/api")
|
|
57
|
+
|
|
58
|
+
def test_key_not_in_repr(self):
|
|
59
|
+
pc = PrivConvert(api_key="pk_live_supersecret")
|
|
60
|
+
self.assertNotIn("supersecret", repr(pc))
|
|
61
|
+
|
|
62
|
+
def test_job_key_not_in_repr(self):
|
|
63
|
+
self.assertNotIn("pjk_secret", repr(privconvert.Job(id="job_1", key="pjk_secret")))
|
|
64
|
+
|
|
65
|
+
def test_bad_tool(self):
|
|
66
|
+
pc = PrivConvert(api_key="pk_live_x")
|
|
67
|
+
for t in ("../me", "a/b", "", "Word To PDF"):
|
|
68
|
+
with self.assertRaises(privconvert.PrivConvertError):
|
|
69
|
+
pc.convert(t, b"x")
|
|
70
|
+
|
|
71
|
+
def test_multipart_header_injection(self):
|
|
72
|
+
body, _ = _multipart([], [("file", 'evil"\r\nX-Injected: 1', b"data")])
|
|
73
|
+
self.assertNotIn(b"\r\nX-Injected", body)
|
|
74
|
+
|
|
75
|
+
def test_filename_parse(self):
|
|
76
|
+
self.assertEqual(_filename_from('attachment; filename="a.pdf"'), "a.pdf")
|
|
77
|
+
self.assertEqual(_filename_from("attachment; filename*=UTF-8''%D7%90.pdf"), "\u05d0.pdf")
|
|
78
|
+
self.assertEqual(_filename_from('attachment; filename="../../etc/x"'), "x")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
82
|
+
calls = []
|
|
83
|
+
|
|
84
|
+
def log_message(self, *a):
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
def _reply(self, status, body, headers=None):
|
|
88
|
+
data = body if isinstance(body, bytes) else json.dumps(body).encode()
|
|
89
|
+
self.send_response(status)
|
|
90
|
+
for k, v in (headers or {}).items():
|
|
91
|
+
self.send_header(k, v)
|
|
92
|
+
self.send_header("Content-Length", str(len(data)))
|
|
93
|
+
self.end_headers()
|
|
94
|
+
self.wfile.write(data)
|
|
95
|
+
|
|
96
|
+
def do_POST(self):
|
|
97
|
+
n = int(self.headers.get("Content-Length", "0"))
|
|
98
|
+
self.rfile.read(n)
|
|
99
|
+
_Handler.calls.append((self.path, self.headers.get("Authorization")))
|
|
100
|
+
if self.path == "/api/convert/busy":
|
|
101
|
+
if sum(1 for p, _ in _Handler.calls if p == self.path) < 2:
|
|
102
|
+
return self._reply(503, {"error": {"code": "server_busy", "message": "busy"}}, {"Retry-After": "0"})
|
|
103
|
+
return self._reply(200, b"OK", {"Content-Disposition": 'attachment; filename="out.txt"'})
|
|
104
|
+
if self.path == "/api/convert/quota":
|
|
105
|
+
return self._reply(429, {"detail": "quota used"}, {"Retry-After": "86400"})
|
|
106
|
+
if self.path == "/api/convert/conc":
|
|
107
|
+
return self._reply(429, {"error": {"code": "concurrency_limited", "message": "slow"}}, {"Retry-After": "0"})
|
|
108
|
+
return self._reply(
|
|
109
|
+
200, b"%PDF-1.4", {"Content-Disposition": 'attachment; filename="x.pdf"', "X-Credits-Remaining": "41"}
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class FakeServerTests(unittest.TestCase):
|
|
114
|
+
@classmethod
|
|
115
|
+
def setUpClass(cls):
|
|
116
|
+
cls.srv = HTTPServer(("127.0.0.1", 0), _Handler)
|
|
117
|
+
threading.Thread(target=cls.srv.serve_forever, daemon=True).start()
|
|
118
|
+
cls.pc = PrivConvert(api_key="pk_live_test", base_url=f"http://127.0.0.1:{cls.srv.server_port}/api")
|
|
119
|
+
|
|
120
|
+
@classmethod
|
|
121
|
+
def tearDownClass(cls):
|
|
122
|
+
cls.srv.shutdown()
|
|
123
|
+
|
|
124
|
+
def test_convert(self):
|
|
125
|
+
r = self.pc.convert("word-to-pdf", b"hello", filename="a.docx")
|
|
126
|
+
self.assertEqual(r.content, b"%PDF-1.4")
|
|
127
|
+
self.assertEqual(r.filename, "x.pdf")
|
|
128
|
+
self.assertEqual(r.credits_remaining, 41)
|
|
129
|
+
self.assertEqual(_Handler.calls[-1][1], "Bearer pk_live_test")
|
|
130
|
+
|
|
131
|
+
def test_retries_busy(self):
|
|
132
|
+
r = self.pc.convert("busy", b"x")
|
|
133
|
+
self.assertEqual(r.content, b"OK")
|
|
134
|
+
|
|
135
|
+
def test_no_long_sleep_on_quota(self):
|
|
136
|
+
with self.assertRaises(privconvert.RateLimitError) as cm:
|
|
137
|
+
self.pc.convert("quota", b"x")
|
|
138
|
+
self.assertEqual(cm.exception.retry_after, 86400)
|
|
139
|
+
self.assertEqual(cm.exception.message, "quota used")
|
|
140
|
+
|
|
141
|
+
def test_concurrency_error_type(self):
|
|
142
|
+
with self.assertRaises(privconvert.ConcurrencyLimitError):
|
|
143
|
+
self.pc.convert("conc", b"x")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__":
|
|
147
|
+
unittest.main()
|