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 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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any