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 +86 -0
- pdfik-0.1.0/PKG-INFO +109 -0
- pdfik-0.1.0/README.md +90 -0
- pdfik-0.1.0/pdfik/__init__.py +34 -0
- pdfik-0.1.0/pdfik/client.py +265 -0
- pdfik-0.1.0/pdfik/exceptions.py +18 -0
- pdfik-0.1.0/pdfik/models.py +155 -0
- pdfik-0.1.0/pyproject.toml +34 -0
- pdfik-0.1.0/tests/test_client.py +182 -0
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
|