pdfik 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.
pdfik-0.1.0/.gitignore ADDED
@@ -0,0 +1,86 @@
1
+ # Dependency directories
2
+ node_modules/
3
+ jspm_packages/
4
+ web_concurrent_snapshots/
5
+
6
+ # Next.js build output
7
+ .next/
8
+ out/
9
+
10
+ # Production build outputs
11
+ dist/
12
+ build/
13
+ bin/
14
+ obj/
15
+
16
+ # Debug logs
17
+ npm-debug.log*
18
+ yarn-debug.log*
19
+ yarn-error.log*
20
+ pnpm-debug.log*
21
+ lerna-debug.log*
22
+
23
+ # Environment files
24
+ .env
25
+ .env.local
26
+ .env.development.local
27
+ .env.test.local
28
+ .env.production.local
29
+ .env*.local
30
+ *.env
31
+
32
+ # Python files
33
+ __pycache__/
34
+ *.py[cod]
35
+ *$py.class
36
+ .ipynb_checkpoints
37
+ .pytest_cache/
38
+ .tox/
39
+ .coverage
40
+ .cache
41
+ nosetests.xml
42
+ coverage.xml
43
+ *.cover
44
+ *.log
45
+
46
+ # Python Virtual environments
47
+ venv/
48
+ .venv/
49
+ env/
50
+ ENV/
51
+ pip-log.txt
52
+ pip-delete-this-directory.txt
53
+
54
+ # Playwright
55
+ playwright-report/
56
+ test-results/
57
+
58
+ # Terraform
59
+ .terraform/
60
+ *.tfstate
61
+ *.tfstate.backup
62
+ .terraform.lock.hcl
63
+ *.tfvars
64
+ *.tfvars.json
65
+
66
+ # Helm
67
+ *.tgz
68
+
69
+ # OS files
70
+ .DS_Store
71
+ Thumbs.db
72
+ desktop.ini
73
+
74
+ # IDEs and editors
75
+ .idea/
76
+ .vscode/
77
+ *.suo
78
+ *.ntvs*
79
+ *.njsproj
80
+ *.sln
81
+ *.sw?
82
+
83
+ # Runtime / temporary files
84
+ **/tmp/
85
+ tmp/
86
+
pdfik-0.1.0/PKG-INFO ADDED
@@ -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.
pdfik-0.1.0/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # pdfik
2
+
3
+ Official Python SDK for [PDFik](https://pdfik.net) — the premium, fast, and reliable PDF generation API.
4
+
5
+ Convert HTML markup or any public URL into pixel-perfect PDF documents in seconds, powered by scalable browser rendering.
6
+
7
+ ## Features
8
+
9
+ - **Type Safety**: Type hints for all options and response objects.
10
+ - **Sync & Async**: Exposes both `PdfikClient` and `AsyncPdfikClient` using the modern `httpx` engine.
11
+ - **Auto Retry**: Automatic exponential backoff for `5xx` and `429` (Rate Limit) errors powered by `tenacity`.
12
+ - **DX Affordances**: Built-in polling logic (`wait_for_job`) and file download helpers.
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ pip install pdfik
18
+ ```
19
+
20
+ ## Quick Start
21
+
22
+ ### Convert public URL to PDF (Sync)
23
+
24
+ ```python
25
+ from pdfik import PdfikClient, PdfOptions, MarginOptions
26
+
27
+ # Initialize the client
28
+ client = PdfikClient(api_key="sk_live_...")
29
+
30
+ # 1. Submit the URL to be rendered
31
+ job = client.url_to_pdf(
32
+ "https://example.com",
33
+ options=PdfOptions(
34
+ format="A4",
35
+ landscape=False,
36
+ print_background=True,
37
+ margin=MarginOptions(top="10mm", bottom="10mm")
38
+ )
39
+ )
40
+
41
+ print(f"Job created: {job.job_id}. Waiting for rendering...")
42
+
43
+ # 2. Poll until the job completes
44
+ result = client.wait_for_job(job.job_id)
45
+ print(f"Job finished! Pages: {result.pages_count}")
46
+
47
+ # 3. Download the PDF bytes
48
+ pdf_bytes = client.download_pdf(job.job_id)
49
+
50
+ with open("output.pdf", "wb") as f:
51
+ f.write(pdf_bytes)
52
+ print("PDF saved to output.pdf")
53
+
54
+ # Close connection pool
55
+ client.close()
56
+ ```
57
+
58
+ ### Convert raw HTML to PDF (Async)
59
+
60
+ ```python
61
+ import asyncio
62
+ from pdfik import AsyncPdfikClient, PdfOptions
63
+
64
+ async def main():
65
+ async with AsyncPdfikClient(api_key="sk_live_...") as client:
66
+ job = await client.html_to_pdf(
67
+ "<h1>Hello World</h1><p>Sent from PDFik Python SDK</p>",
68
+ options=PdfOptions(format="Letter")
69
+ )
70
+
71
+ result = await client.wait_for_job(job.job_id)
72
+ print(f"Job finished! Status: {result.status}")
73
+
74
+ pdf_bytes = await client.download_pdf(job.job_id)
75
+
76
+ asyncio.run(main())
77
+ ```
78
+
79
+ ### Get a temporary, pre-signed download URL
80
+
81
+ 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:
82
+
83
+ ```python
84
+ file_info = client.get_file_url(job.job_id)
85
+ print(f"Download URL: {file_info.download_url}")
86
+ ```
87
+
88
+ ## License
89
+
90
+ MIT License.
@@ -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
+ ]
@@ -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()
@@ -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}"
@@ -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,34 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "pdfik"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for the PDFik PDF generation API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = {text = "MIT"}
12
+ authors = [
13
+ {name = "PDFik", email = "support@pdfik.net"}
14
+ ]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ ]
20
+ dependencies = [
21
+ "httpx>=0.24.0",
22
+ "tenacity>=8.0.0",
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ dev = [
27
+ "pytest>=7.0.0",
28
+ "pytest-asyncio>=0.21.0",
29
+ "respx>=0.20.0",
30
+ "mypy>=1.0.0",
31
+ ]
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["pdfik"]
@@ -0,0 +1,182 @@
1
+ import pytest
2
+ import respx
3
+ import httpx
4
+ from pdfik import (
5
+ PdfikClient,
6
+ AsyncPdfikClient,
7
+ PdfikError,
8
+ PdfOptions,
9
+ RenderOptions,
10
+ MarginOptions,
11
+ )
12
+
13
+ # Sync Tests
14
+
15
+ @respx.mock
16
+ def test_url_to_pdf_request():
17
+ route = respx.post("https://api.pdfik.net/url-to-pdf").mock(
18
+ return_value=httpx.Response(
19
+ 202,
20
+ json={"job_id": "job-123", "status": "queued", "detail": "Job successfully queued"}
21
+ )
22
+ )
23
+
24
+ client = PdfikClient(api_key="sk_test_123")
25
+ res = client.url_to_pdf(
26
+ "https://example.com",
27
+ webhook_url="https://webhook.com",
28
+ options=PdfOptions(margin=MarginOptions(top="10px"))
29
+ )
30
+
31
+ assert res.job_id == "job-123"
32
+ assert res.status == "queued"
33
+ assert route.called
34
+ request = route.calls[0].request
35
+ assert request.headers["X-API-Key"] == "sk_test_123"
36
+ assert "application/json" in request.headers["Content-Type"]
37
+
38
+ import json
39
+ body = json.loads(request.read())
40
+ assert body["url"] == "https://example.com"
41
+ assert body["webhook_url"] == "https://webhook.com"
42
+ assert body["options"]["margin"]["top"] == "10px"
43
+
44
+
45
+ @respx.mock
46
+ def test_html_to_pdf_request():
47
+ route = respx.post("https://api.pdfik.net/html-to-pdf").mock(
48
+ return_value=httpx.Response(
49
+ 202,
50
+ json={"job_id": "job-456", "status": "queued", "detail": "Job successfully queued"}
51
+ )
52
+ )
53
+
54
+ client = PdfikClient(api_key="sk_test_123")
55
+ res = client.html_to_pdf("<h1>Hello</h1>", options=PdfOptions(landscape=True))
56
+
57
+ assert res.job_id == "job-456"
58
+ assert res.status == "queued"
59
+ assert route.called
60
+ request = route.calls[0].request
61
+
62
+ import json
63
+ body = json.loads(request.read())
64
+ assert body["html"] == "<h1>Hello</h1>"
65
+ assert body["options"]["landscape"] is True
66
+
67
+
68
+ @respx.mock
69
+ def test_wait_for_job_polls():
70
+ get_route = respx.get(url__startswith="https://api.pdfik.net/jobs/job-123")
71
+ get_route.side_effect = [
72
+ httpx.Response(200, json={"status": "queued"}),
73
+ httpx.Response(200, json={"status": "rendering"}),
74
+ httpx.Response(200, json={"status": "done", "pages_count": 3}),
75
+ ]
76
+
77
+ client = PdfikClient(api_key="sk_test_123")
78
+ res = client.wait_for_job("job-123", poll_interval=0.01)
79
+
80
+ assert res.status == "done"
81
+ assert res.pages_count == 3
82
+ assert get_route.call_count == 3
83
+
84
+
85
+ @respx.mock
86
+ def test_wait_for_job_raises_on_failed():
87
+ get_route = respx.get("https://api.pdfik.net/jobs/job-123").mock(
88
+ return_value=httpx.Response(200, json={"status": "failed", "error_code": "SSRF_BLOCKED"})
89
+ )
90
+
91
+ client = PdfikClient(api_key="sk_test_123")
92
+ with pytest.raises(PdfikError) as exc_info:
93
+ client.wait_for_job("job-123", poll_interval=0.01)
94
+
95
+ assert exc_info.value.status_code == 400
96
+ assert exc_info.value.error_code == "SSRF_BLOCKED"
97
+
98
+
99
+ @respx.mock
100
+ def test_wait_for_job_timeout():
101
+ get_route = respx.get("https://api.pdfik.net/jobs/job-123").mock(
102
+ return_value=httpx.Response(200, json={"status": "queued"})
103
+ )
104
+
105
+ client = PdfikClient(api_key="sk_test_123")
106
+ with pytest.raises(PdfikError) as exc_info:
107
+ client.wait_for_job("job-123", timeout=1, poll_interval=0.2)
108
+
109
+ assert exc_info.value.status_code == 408
110
+ assert "timed out" in str(exc_info.value)
111
+
112
+
113
+ @respx.mock
114
+ def test_retry_on_500():
115
+ get_route = respx.get("https://api.pdfik.net/jobs/job-123").mock(
116
+ return_value=httpx.Response(502, text="Bad Gateway")
117
+ )
118
+
119
+ # Patch tenacity backoff wait to avoid delays in tests
120
+ client = PdfikClient(api_key="sk_test_123")
121
+
122
+ # We can modify the tenacity retry settings dynamically for testing or rely on mock patching.
123
+ # To keep it simple, we patch wait_exponential in tenacity to be 0
124
+ from tenacity import wait_none
125
+ client._request.retry.wait = wait_none()
126
+
127
+ with pytest.raises(PdfikError) as exc_info:
128
+ client.get_job("job-123")
129
+
130
+ assert exc_info.value.status_code == 502
131
+ assert get_route.call_count == 3
132
+
133
+
134
+ @respx.mock
135
+ def test_no_retry_on_401():
136
+ get_route = respx.get("https://api.pdfik.net/jobs/job-123").mock(
137
+ return_value=httpx.Response(401, json={"detail": "Invalid API key"})
138
+ )
139
+
140
+ client = PdfikClient(api_key="sk_test_123")
141
+ with pytest.raises(PdfikError) as exc_info:
142
+ client.get_job("job-123")
143
+
144
+ assert exc_info.value.status_code == 401
145
+ assert get_route.call_count == 1
146
+
147
+
148
+ # Async Tests
149
+
150
+ @pytest.mark.asyncio
151
+ @respx.mock
152
+ async def test_async_url_to_pdf():
153
+ route = respx.post("https://api.pdfik.net/url-to-pdf").mock(
154
+ return_value=httpx.Response(
155
+ 202,
156
+ json={"job_id": "job-async", "status": "queued", "detail": "Job successfully queued"}
157
+ )
158
+ )
159
+
160
+ async with AsyncPdfikClient(api_key="sk_test_123") as client:
161
+ res = await client.url_to_pdf("https://example.com")
162
+
163
+ assert res.job_id == "job-async"
164
+ assert res.status == "queued"
165
+ assert route.called
166
+
167
+
168
+ @pytest.mark.asyncio
169
+ @respx.mock
170
+ async def test_async_wait_for_job():
171
+ get_route = respx.get(url__startswith="https://api.pdfik.net/jobs/job-async")
172
+ get_route.side_effect = [
173
+ httpx.Response(200, json={"status": "rendering"}),
174
+ httpx.Response(200, json={"status": "done", "pages_count": 2}),
175
+ ]
176
+
177
+ async with AsyncPdfikClient(api_key="sk_test_123") as client:
178
+ res = await client.wait_for_job("job-async", poll_interval=0.01)
179
+
180
+ assert res.status == "done"
181
+ assert res.pages_count == 2
182
+ assert get_route.call_count == 2