pdfik 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pdfik/__init__.py +34 -0
- pdfik/client.py +265 -0
- pdfik/exceptions.py +18 -0
- pdfik/models.py +155 -0
- pdfik-0.1.0.dist-info/METADATA +109 -0
- pdfik-0.1.0.dist-info/RECORD +7 -0
- pdfik-0.1.0.dist-info/WHEEL +4 -0
pdfik/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from .client import PdfikClient, AsyncPdfikClient
|
|
2
|
+
from .exceptions import PdfikError
|
|
3
|
+
from .models import (
|
|
4
|
+
PaperFormat,
|
|
5
|
+
WaitUntilEvent,
|
|
6
|
+
JobStatus,
|
|
7
|
+
MarginOptions,
|
|
8
|
+
WatermarkOptions,
|
|
9
|
+
CompressionOptions,
|
|
10
|
+
PdfOptions,
|
|
11
|
+
RenderOptions,
|
|
12
|
+
JobCreatedResponse,
|
|
13
|
+
JobMetrics,
|
|
14
|
+
JobStatusResponse,
|
|
15
|
+
JobFileResponse,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"PdfikClient",
|
|
20
|
+
"AsyncPdfikClient",
|
|
21
|
+
"PdfikError",
|
|
22
|
+
"PaperFormat",
|
|
23
|
+
"WaitUntilEvent",
|
|
24
|
+
"JobStatus",
|
|
25
|
+
"MarginOptions",
|
|
26
|
+
"WatermarkOptions",
|
|
27
|
+
"CompressionOptions",
|
|
28
|
+
"PdfOptions",
|
|
29
|
+
"RenderOptions",
|
|
30
|
+
"JobCreatedResponse",
|
|
31
|
+
"JobMetrics",
|
|
32
|
+
"JobStatusResponse",
|
|
33
|
+
"JobFileResponse",
|
|
34
|
+
]
|
pdfik/client.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import time
|
|
2
|
+
import asyncio
|
|
3
|
+
from typing import Optional
|
|
4
|
+
import httpx
|
|
5
|
+
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception
|
|
6
|
+
|
|
7
|
+
from .exceptions import PdfikError
|
|
8
|
+
from .models import (
|
|
9
|
+
JobCreatedResponse,
|
|
10
|
+
JobStatusResponse,
|
|
11
|
+
JobFileResponse,
|
|
12
|
+
PdfOptions,
|
|
13
|
+
RenderOptions,
|
|
14
|
+
_to_dict
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
def _is_retryable_exception(exception: BaseException) -> bool:
|
|
18
|
+
return isinstance(exception, PdfikError) and (exception.status_code >= 500 or exception.status_code == 429)
|
|
19
|
+
|
|
20
|
+
class PdfikClient:
|
|
21
|
+
def __init__(self, api_key: str, base_url: str = "https://api.pdfik.net"):
|
|
22
|
+
if not api_key:
|
|
23
|
+
raise ValueError("API key is required")
|
|
24
|
+
self.api_key = api_key
|
|
25
|
+
self.base_url = base_url.rstrip("/")
|
|
26
|
+
self.client = httpx.Client()
|
|
27
|
+
|
|
28
|
+
@retry(
|
|
29
|
+
stop=stop_after_attempt(3),
|
|
30
|
+
wait=wait_exponential(multiplier=1, min=1, max=4),
|
|
31
|
+
retry=retry_if_exception(_is_retryable_exception),
|
|
32
|
+
reraise=True
|
|
33
|
+
)
|
|
34
|
+
def _request(self, method: str, path: str, json_data: Optional[dict] = None) -> httpx.Response:
|
|
35
|
+
url = f"{self.base_url}{path}"
|
|
36
|
+
headers = {
|
|
37
|
+
"X-API-Key": self.api_key,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
response = self.client.request(method, url, headers=headers, json=json_data)
|
|
42
|
+
except httpx.RequestError as exc:
|
|
43
|
+
raise PdfikError(f"Connection failed: {exc}", 503)
|
|
44
|
+
|
|
45
|
+
if response.is_error:
|
|
46
|
+
error_code = None
|
|
47
|
+
message = f"API request failed with status {response.status_code}"
|
|
48
|
+
response_body = response.text
|
|
49
|
+
try:
|
|
50
|
+
data = response.json()
|
|
51
|
+
if isinstance(data, dict):
|
|
52
|
+
message = data.get("detail", message)
|
|
53
|
+
error_code = data.get("error_code")
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
raise PdfikError(message, response.status_code, error_code, response_body)
|
|
57
|
+
|
|
58
|
+
return response
|
|
59
|
+
|
|
60
|
+
def url_to_pdf(
|
|
61
|
+
self,
|
|
62
|
+
url: str,
|
|
63
|
+
*,
|
|
64
|
+
webhook_url: Optional[str] = None,
|
|
65
|
+
options: Optional[PdfOptions] = None,
|
|
66
|
+
render: Optional[RenderOptions] = None
|
|
67
|
+
) -> JobCreatedResponse:
|
|
68
|
+
body = {"url": url}
|
|
69
|
+
if webhook_url:
|
|
70
|
+
body["webhook_url"] = webhook_url
|
|
71
|
+
if options:
|
|
72
|
+
body["options"] = _to_dict(options)
|
|
73
|
+
if render:
|
|
74
|
+
body["render"] = _to_dict(render)
|
|
75
|
+
|
|
76
|
+
res = self._request("POST", "/url-to-pdf", json_data=body)
|
|
77
|
+
return JobCreatedResponse.from_dict(res.json())
|
|
78
|
+
|
|
79
|
+
def html_to_pdf(
|
|
80
|
+
self,
|
|
81
|
+
html: str,
|
|
82
|
+
*,
|
|
83
|
+
webhook_url: Optional[str] = None,
|
|
84
|
+
options: Optional[PdfOptions] = None,
|
|
85
|
+
render: Optional[RenderOptions] = None
|
|
86
|
+
) -> JobCreatedResponse:
|
|
87
|
+
body = {"html": html}
|
|
88
|
+
if webhook_url:
|
|
89
|
+
body["webhook_url"] = webhook_url
|
|
90
|
+
if options:
|
|
91
|
+
body["options"] = _to_dict(options)
|
|
92
|
+
if render:
|
|
93
|
+
body["render"] = _to_dict(render)
|
|
94
|
+
|
|
95
|
+
res = self._request("POST", "/html-to-pdf", json_data=body)
|
|
96
|
+
return JobCreatedResponse.from_dict(res.json())
|
|
97
|
+
|
|
98
|
+
def get_job(self, job_id: str) -> JobStatusResponse:
|
|
99
|
+
res = self._request("GET", f"/jobs/{job_id}")
|
|
100
|
+
return JobStatusResponse.from_dict(res.json())
|
|
101
|
+
|
|
102
|
+
def wait_for_job(
|
|
103
|
+
self,
|
|
104
|
+
job_id: str,
|
|
105
|
+
*,
|
|
106
|
+
timeout: int = 120,
|
|
107
|
+
poll_interval: int = 2
|
|
108
|
+
) -> JobStatusResponse:
|
|
109
|
+
start_time = time.time()
|
|
110
|
+
while True:
|
|
111
|
+
job = self.get_job(job_id)
|
|
112
|
+
if job.status in ("done", "failed"):
|
|
113
|
+
if job.status == "failed":
|
|
114
|
+
raise PdfikError(
|
|
115
|
+
f"Job {job_id} failed",
|
|
116
|
+
400,
|
|
117
|
+
job.error_code or "JOB_FAILED"
|
|
118
|
+
)
|
|
119
|
+
return job
|
|
120
|
+
|
|
121
|
+
if time.time() - start_time >= timeout:
|
|
122
|
+
raise PdfikError(f"Job {job_id} timed out", 408, "TIMEOUT")
|
|
123
|
+
|
|
124
|
+
time.sleep(poll_interval)
|
|
125
|
+
|
|
126
|
+
def get_file_url(self, job_id: str) -> JobFileResponse:
|
|
127
|
+
res = self._request("GET", f"/jobs/{job_id}/file")
|
|
128
|
+
return JobFileResponse.from_dict(res.json())
|
|
129
|
+
|
|
130
|
+
def download_pdf(self, job_id: str) -> bytes:
|
|
131
|
+
res = self._request("GET", f"/jobs/{job_id}/download")
|
|
132
|
+
return res.content
|
|
133
|
+
|
|
134
|
+
def close(self):
|
|
135
|
+
self.client.close()
|
|
136
|
+
|
|
137
|
+
def __enter__(self):
|
|
138
|
+
return self
|
|
139
|
+
|
|
140
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
141
|
+
self.close()
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class AsyncPdfikClient:
|
|
145
|
+
def __init__(self, api_key: str, base_url: str = "https://api.pdfik.net"):
|
|
146
|
+
if not api_key:
|
|
147
|
+
raise ValueError("API key is required")
|
|
148
|
+
self.api_key = api_key
|
|
149
|
+
self.base_url = base_url.rstrip("/")
|
|
150
|
+
self.client = httpx.AsyncClient()
|
|
151
|
+
|
|
152
|
+
@retry(
|
|
153
|
+
stop=stop_after_attempt(3),
|
|
154
|
+
wait=wait_exponential(multiplier=1, min=1, max=4),
|
|
155
|
+
retry=retry_if_exception(_is_retryable_exception),
|
|
156
|
+
reraise=True
|
|
157
|
+
)
|
|
158
|
+
async def _request(self, method: str, path: str, json_data: Optional[dict] = None) -> httpx.Response:
|
|
159
|
+
url = f"{self.base_url}{path}"
|
|
160
|
+
headers = {
|
|
161
|
+
"X-API-Key": self.api_key,
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
response = await self.client.request(method, url, headers=headers, json=json_data)
|
|
166
|
+
except httpx.RequestError as exc:
|
|
167
|
+
raise PdfikError(f"Connection failed: {exc}", 503)
|
|
168
|
+
|
|
169
|
+
if response.is_error:
|
|
170
|
+
error_code = None
|
|
171
|
+
message = f"API request failed with status {response.status_code}"
|
|
172
|
+
response_body = response.text
|
|
173
|
+
try:
|
|
174
|
+
data = response.json()
|
|
175
|
+
if isinstance(data, dict):
|
|
176
|
+
message = data.get("detail", message)
|
|
177
|
+
error_code = data.get("error_code")
|
|
178
|
+
except Exception:
|
|
179
|
+
pass
|
|
180
|
+
raise PdfikError(message, response.status_code, error_code, response_body)
|
|
181
|
+
|
|
182
|
+
return response
|
|
183
|
+
|
|
184
|
+
async def url_to_pdf(
|
|
185
|
+
self,
|
|
186
|
+
url: str,
|
|
187
|
+
*,
|
|
188
|
+
webhook_url: Optional[str] = None,
|
|
189
|
+
options: Optional[PdfOptions] = None,
|
|
190
|
+
render: Optional[RenderOptions] = None
|
|
191
|
+
) -> JobCreatedResponse:
|
|
192
|
+
body = {"url": url}
|
|
193
|
+
if webhook_url:
|
|
194
|
+
body["webhook_url"] = webhook_url
|
|
195
|
+
if options:
|
|
196
|
+
body["options"] = _to_dict(options)
|
|
197
|
+
if render:
|
|
198
|
+
body["render"] = _to_dict(render)
|
|
199
|
+
|
|
200
|
+
res = await self._request("POST", "/url-to-pdf", json_data=body)
|
|
201
|
+
return JobCreatedResponse.from_dict(res.json())
|
|
202
|
+
|
|
203
|
+
async def html_to_pdf(
|
|
204
|
+
self,
|
|
205
|
+
html: str,
|
|
206
|
+
*,
|
|
207
|
+
webhook_url: Optional[str] = None,
|
|
208
|
+
options: Optional[PdfOptions] = None,
|
|
209
|
+
render: Optional[RenderOptions] = None
|
|
210
|
+
) -> JobCreatedResponse:
|
|
211
|
+
body = {"html": html}
|
|
212
|
+
if webhook_url:
|
|
213
|
+
body["webhook_url"] = webhook_url
|
|
214
|
+
if options:
|
|
215
|
+
body["options"] = _to_dict(options)
|
|
216
|
+
if render:
|
|
217
|
+
body["render"] = _to_dict(render)
|
|
218
|
+
|
|
219
|
+
res = await self._request("POST", "/html-to-pdf", json_data=body)
|
|
220
|
+
return JobCreatedResponse.from_dict(res.json())
|
|
221
|
+
|
|
222
|
+
async def get_job(self, job_id: str) -> JobStatusResponse:
|
|
223
|
+
res = await self._request("GET", f"/jobs/{job_id}")
|
|
224
|
+
return JobStatusResponse.from_dict(res.json())
|
|
225
|
+
|
|
226
|
+
async def wait_for_job(
|
|
227
|
+
self,
|
|
228
|
+
job_id: str,
|
|
229
|
+
*,
|
|
230
|
+
timeout: int = 120,
|
|
231
|
+
poll_interval: int = 2
|
|
232
|
+
) -> JobStatusResponse:
|
|
233
|
+
start_time = time.time()
|
|
234
|
+
while True:
|
|
235
|
+
job = await self.get_job(job_id)
|
|
236
|
+
if job.status in ("done", "failed"):
|
|
237
|
+
if job.status == "failed":
|
|
238
|
+
raise PdfikError(
|
|
239
|
+
f"Job {job_id} failed",
|
|
240
|
+
400,
|
|
241
|
+
job.error_code or "JOB_FAILED"
|
|
242
|
+
)
|
|
243
|
+
return job
|
|
244
|
+
|
|
245
|
+
if time.time() - start_time >= timeout:
|
|
246
|
+
raise PdfikError(f"Job {job_id} timed out", 408, "TIMEOUT")
|
|
247
|
+
|
|
248
|
+
await asyncio.sleep(poll_interval)
|
|
249
|
+
|
|
250
|
+
async def get_file_url(self, job_id: str) -> JobFileResponse:
|
|
251
|
+
res = await self._request("GET", f"/jobs/{job_id}/file")
|
|
252
|
+
return JobFileResponse.from_dict(res.json())
|
|
253
|
+
|
|
254
|
+
async def download_pdf(self, job_id: str) -> bytes:
|
|
255
|
+
res = await self._request("GET", f"/jobs/{job_id}/download")
|
|
256
|
+
return res.content
|
|
257
|
+
|
|
258
|
+
async def close(self):
|
|
259
|
+
await self.client.aclose()
|
|
260
|
+
|
|
261
|
+
async def __aenter__(self):
|
|
262
|
+
return self
|
|
263
|
+
|
|
264
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
265
|
+
await self.close()
|
pdfik/exceptions.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
class PdfikError(Exception):
|
|
4
|
+
def __init__(
|
|
5
|
+
self,
|
|
6
|
+
message: str,
|
|
7
|
+
status_code: int,
|
|
8
|
+
error_code: Optional[str] = None,
|
|
9
|
+
response_body: Optional[str] = None
|
|
10
|
+
):
|
|
11
|
+
super().__init__(message)
|
|
12
|
+
self.status_code = status_code
|
|
13
|
+
self.error_code = error_code
|
|
14
|
+
self.response_body = response_body
|
|
15
|
+
|
|
16
|
+
def __str__(self):
|
|
17
|
+
code_str = f" [{self.error_code}]" if self.error_code else ""
|
|
18
|
+
return f"{self.args[0]} (HTTP {self.status_code}){code_str}"
|
pdfik/models.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import Optional, Any, Dict, Type, TypeVar, get_type_hints
|
|
4
|
+
|
|
5
|
+
T = TypeVar('T')
|
|
6
|
+
|
|
7
|
+
class PaperFormat(str, Enum):
|
|
8
|
+
A4 = "A4"
|
|
9
|
+
A3 = "A3"
|
|
10
|
+
LETTER = "Letter"
|
|
11
|
+
LEGAL = "Legal"
|
|
12
|
+
TABLOID = "Tabloid"
|
|
13
|
+
|
|
14
|
+
class WaitUntilEvent(str, Enum):
|
|
15
|
+
LOAD = "load"
|
|
16
|
+
DOM_CONTENT_LOADED = "domcontentloaded"
|
|
17
|
+
NETWORK_IDLE = "networkidle"
|
|
18
|
+
COMMIT = "commit"
|
|
19
|
+
|
|
20
|
+
class JobStatus(str, Enum):
|
|
21
|
+
QUEUED = "queued"
|
|
22
|
+
RENDERING = "rendering"
|
|
23
|
+
UPLOADING = "uploading"
|
|
24
|
+
DONE = "done"
|
|
25
|
+
FAILED = "failed"
|
|
26
|
+
|
|
27
|
+
def _from_dict(cls: Type[T], data: Any) -> Any:
|
|
28
|
+
if data is None:
|
|
29
|
+
return None
|
|
30
|
+
if not isinstance(data, dict):
|
|
31
|
+
return data
|
|
32
|
+
|
|
33
|
+
# Get class type hints
|
|
34
|
+
try:
|
|
35
|
+
type_hints = get_type_hints(cls)
|
|
36
|
+
except Exception:
|
|
37
|
+
type_hints = {}
|
|
38
|
+
|
|
39
|
+
kwargs = {}
|
|
40
|
+
for name, field_type in type_hints.items():
|
|
41
|
+
if name in data:
|
|
42
|
+
val = data[name]
|
|
43
|
+
# Handle list of nested models if any (currently none, but good to have)
|
|
44
|
+
# Handle nested dataclasses
|
|
45
|
+
if hasattr(field_type, '__dataclass_fields__'):
|
|
46
|
+
kwargs[name] = _from_dict(field_type, val)
|
|
47
|
+
# Handle Optional[dataclass]
|
|
48
|
+
elif hasattr(field_type, '__args__'):
|
|
49
|
+
# Extract the inner type from Union/Optional
|
|
50
|
+
args = field_type.__args__
|
|
51
|
+
inner_type = next((a for a in args if hasattr(a, '__dataclass_fields__')), None)
|
|
52
|
+
if inner_type and val is not None:
|
|
53
|
+
kwargs[name] = _from_dict(inner_type, val)
|
|
54
|
+
else:
|
|
55
|
+
kwargs[name] = val
|
|
56
|
+
else:
|
|
57
|
+
kwargs[name] = val
|
|
58
|
+
|
|
59
|
+
return cls(**kwargs)
|
|
60
|
+
|
|
61
|
+
def _to_dict(obj: Any) -> Any:
|
|
62
|
+
if hasattr(obj, '__dataclass_fields__'):
|
|
63
|
+
res = {}
|
|
64
|
+
for name in obj.__dataclass_fields__:
|
|
65
|
+
val = getattr(obj, name)
|
|
66
|
+
if val is not None:
|
|
67
|
+
res[name] = _to_dict(val)
|
|
68
|
+
return res
|
|
69
|
+
elif isinstance(obj, Enum):
|
|
70
|
+
return obj.value
|
|
71
|
+
elif isinstance(obj, list):
|
|
72
|
+
return [_to_dict(x) for x in obj]
|
|
73
|
+
elif isinstance(obj, dict):
|
|
74
|
+
return {k: _to_dict(v) for k, v in obj.items() if v is not None}
|
|
75
|
+
return obj
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class MarginOptions:
|
|
79
|
+
top: Optional[str] = None
|
|
80
|
+
right: Optional[str] = None
|
|
81
|
+
bottom: Optional[str] = None
|
|
82
|
+
left: Optional[str] = None
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class WatermarkOptions:
|
|
86
|
+
text: str
|
|
87
|
+
color: Optional[str] = None
|
|
88
|
+
font_size: Optional[str] = None
|
|
89
|
+
rotation_degrees: Optional[int] = None
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class CompressionOptions:
|
|
93
|
+
level: Optional[int] = None
|
|
94
|
+
image_quality: Optional[int] = None
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class PdfOptions:
|
|
98
|
+
format: Optional[PaperFormat] = None
|
|
99
|
+
landscape: Optional[bool] = None
|
|
100
|
+
margin: Optional[MarginOptions] = None
|
|
101
|
+
print_background: Optional[bool] = None
|
|
102
|
+
display_header_footer: Optional[bool] = None
|
|
103
|
+
header_template: Optional[str] = None
|
|
104
|
+
footer_template: Optional[str] = None
|
|
105
|
+
watermark: Optional[WatermarkOptions] = None
|
|
106
|
+
user_password: Optional[str] = None
|
|
107
|
+
compression: Optional[CompressionOptions] = None
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class RenderOptions:
|
|
111
|
+
page_load_timeout_ms: Optional[int] = None
|
|
112
|
+
wait_until: Optional[WaitUntilEvent] = None
|
|
113
|
+
wait_for_selector: Optional[str] = None
|
|
114
|
+
wait_after_load_ms: Optional[int] = None
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class JobCreatedResponse:
|
|
118
|
+
job_id: str
|
|
119
|
+
status: str
|
|
120
|
+
detail: str
|
|
121
|
+
|
|
122
|
+
@classmethod
|
|
123
|
+
def from_dict(cls: Type[T], data: dict) -> T:
|
|
124
|
+
return _from_dict(cls, data)
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class JobMetrics:
|
|
128
|
+
file_size_bytes: Optional[int] = None
|
|
129
|
+
file_size_human: Optional[str] = None
|
|
130
|
+
page_load_ms: Optional[int] = None
|
|
131
|
+
total_duration_ms: Optional[int] = None
|
|
132
|
+
page_count: Optional[int] = None
|
|
133
|
+
|
|
134
|
+
@dataclass
|
|
135
|
+
class JobStatusResponse:
|
|
136
|
+
status: JobStatus
|
|
137
|
+
created_at: Optional[str] = None
|
|
138
|
+
finished_at: Optional[str] = None
|
|
139
|
+
pages_count: Optional[int] = None
|
|
140
|
+
error_code: Optional[str] = None
|
|
141
|
+
metrics: Optional[JobMetrics] = None
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
def from_dict(cls: Type[T], data: dict) -> T:
|
|
145
|
+
return _from_dict(cls, data)
|
|
146
|
+
|
|
147
|
+
@dataclass
|
|
148
|
+
class JobFileResponse:
|
|
149
|
+
job_id: str
|
|
150
|
+
download_url: str
|
|
151
|
+
expires_in_seconds: int
|
|
152
|
+
|
|
153
|
+
@classmethod
|
|
154
|
+
def from_dict(cls: Type[T], data: dict) -> T:
|
|
155
|
+
return _from_dict(cls, data)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pdfik
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the PDFik PDF generation API
|
|
5
|
+
Author-email: PDFik <support@pdfik.net>
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Requires-Python: >=3.8
|
|
11
|
+
Requires-Dist: httpx>=0.24.0
|
|
12
|
+
Requires-Dist: tenacity>=8.0.0
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: mypy>=1.0.0; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
|
|
16
|
+
Requires-Dist: pytest>=7.0.0; extra == 'dev'
|
|
17
|
+
Requires-Dist: respx>=0.20.0; extra == 'dev'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# pdfik
|
|
21
|
+
|
|
22
|
+
Official Python SDK for [PDFik](https://pdfik.net) — the premium, fast, and reliable PDF generation API.
|
|
23
|
+
|
|
24
|
+
Convert HTML markup or any public URL into pixel-perfect PDF documents in seconds, powered by scalable browser rendering.
|
|
25
|
+
|
|
26
|
+
## Features
|
|
27
|
+
|
|
28
|
+
- **Type Safety**: Type hints for all options and response objects.
|
|
29
|
+
- **Sync & Async**: Exposes both `PdfikClient` and `AsyncPdfikClient` using the modern `httpx` engine.
|
|
30
|
+
- **Auto Retry**: Automatic exponential backoff for `5xx` and `429` (Rate Limit) errors powered by `tenacity`.
|
|
31
|
+
- **DX Affordances**: Built-in polling logic (`wait_for_job`) and file download helpers.
|
|
32
|
+
|
|
33
|
+
## Installation
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install pdfik
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quick Start
|
|
40
|
+
|
|
41
|
+
### Convert public URL to PDF (Sync)
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from pdfik import PdfikClient, PdfOptions, MarginOptions
|
|
45
|
+
|
|
46
|
+
# Initialize the client
|
|
47
|
+
client = PdfikClient(api_key="sk_live_...")
|
|
48
|
+
|
|
49
|
+
# 1. Submit the URL to be rendered
|
|
50
|
+
job = client.url_to_pdf(
|
|
51
|
+
"https://example.com",
|
|
52
|
+
options=PdfOptions(
|
|
53
|
+
format="A4",
|
|
54
|
+
landscape=False,
|
|
55
|
+
print_background=True,
|
|
56
|
+
margin=MarginOptions(top="10mm", bottom="10mm")
|
|
57
|
+
)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
print(f"Job created: {job.job_id}. Waiting for rendering...")
|
|
61
|
+
|
|
62
|
+
# 2. Poll until the job completes
|
|
63
|
+
result = client.wait_for_job(job.job_id)
|
|
64
|
+
print(f"Job finished! Pages: {result.pages_count}")
|
|
65
|
+
|
|
66
|
+
# 3. Download the PDF bytes
|
|
67
|
+
pdf_bytes = client.download_pdf(job.job_id)
|
|
68
|
+
|
|
69
|
+
with open("output.pdf", "wb") as f:
|
|
70
|
+
f.write(pdf_bytes)
|
|
71
|
+
print("PDF saved to output.pdf")
|
|
72
|
+
|
|
73
|
+
# Close connection pool
|
|
74
|
+
client.close()
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Convert raw HTML to PDF (Async)
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
import asyncio
|
|
81
|
+
from pdfik import AsyncPdfikClient, PdfOptions
|
|
82
|
+
|
|
83
|
+
async def main():
|
|
84
|
+
async with AsyncPdfikClient(api_key="sk_live_...") as client:
|
|
85
|
+
job = await client.html_to_pdf(
|
|
86
|
+
"<h1>Hello World</h1><p>Sent from PDFik Python SDK</p>",
|
|
87
|
+
options=PdfOptions(format="Letter")
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
result = await client.wait_for_job(job.job_id)
|
|
91
|
+
print(f"Job finished! Status: {result.status}")
|
|
92
|
+
|
|
93
|
+
pdf_bytes = await client.download_pdf(job.job_id)
|
|
94
|
+
|
|
95
|
+
asyncio.run(main())
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Get a temporary, pre-signed download URL
|
|
99
|
+
|
|
100
|
+
If you prefer not to download the bytes directly through your application server, you can generate a secure pre-signed URL (expires in 15 minutes) for the user's browser to download:
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
file_info = client.get_file_url(job.job_id)
|
|
104
|
+
print(f"Download URL: {file_info.download_url}")
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT License.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
pdfik/__init__.py,sha256=cR3KgwwEMpUbBRb6fnOdYGF4EffdrbkJGvB3xvN8AWc,681
|
|
2
|
+
pdfik/client.py,sha256=mPyA-QZvhZJRbs5net9X3PcuMcM9F0m16GiO-9P8kx0,8758
|
|
3
|
+
pdfik/exceptions.py,sha256=XHvX7xqCkhNk-huNC3nmSEpeHPQrr90TU200hg92H18,547
|
|
4
|
+
pdfik/models.py,sha256=Z0VdIM0KJ9OrMoDsHS2o-d1sRIbU1uvwVwSuwpIJoWQ,4384
|
|
5
|
+
pdfik-0.1.0.dist-info/METADATA,sha256=ZFq7g1n6_Gkcpo4bvBG4LL7Cm5rfsQJKJkZIHzzANwY,3096
|
|
6
|
+
pdfik-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
7
|
+
pdfik-0.1.0.dist-info/RECORD,,
|