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.
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ dist/
4
+ build/
5
+ *.egg-info/
6
+ .venv/
@@ -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()