ironfang-render 1.0.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.
- ironfang_render-1.0.0/PKG-INFO +103 -0
- ironfang_render-1.0.0/README.md +87 -0
- ironfang_render-1.0.0/pyproject.toml +30 -0
- ironfang_render-1.0.0/setup.cfg +4 -0
- ironfang_render-1.0.0/src/ironfang_render/__init__.py +33 -0
- ironfang_render-1.0.0/src/ironfang_render/client.py +354 -0
- ironfang_render-1.0.0/src/ironfang_render/errors.py +24 -0
- ironfang_render-1.0.0/src/ironfang_render/webhook.py +48 -0
- ironfang_render-1.0.0/src/ironfang_render.egg-info/PKG-INFO +103 -0
- ironfang_render-1.0.0/src/ironfang_render.egg-info/SOURCES.txt +13 -0
- ironfang_render-1.0.0/src/ironfang_render.egg-info/dependency_links.txt +1 -0
- ironfang_render-1.0.0/src/ironfang_render.egg-info/top_level.txt +2 -0
- ironfang_render-1.0.0/src/ironfang_renderwolf/__init__.py +22 -0
- ironfang_render-1.0.0/tests/test_client.py +249 -0
- ironfang_render-1.0.0/tests/test_compat.py +17 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ironfang-render
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Ironfang Render API client: screenshots, PDFs, QR codes, template images, clips and site previews from a URL or HTML.
|
|
5
|
+
Author: Ironfang Ltd
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://ironfang.com/render
|
|
8
|
+
Project-URL: Documentation, https://ironfang.com/render/docs
|
|
9
|
+
Keywords: screenshot,pdf,qr,html-to-image,ironfang
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# ironfang-render
|
|
18
|
+
|
|
19
|
+
The official Python client for the [Ironfang Render](https://ironfang.com/render)
|
|
20
|
+
API: screenshots, PDFs, QR codes, template images, clips and site previews
|
|
21
|
+
from a URL or from HTML you send. No dependencies; Python 3.10 and later.
|
|
22
|
+
|
|
23
|
+
Formerly `ironfang-renderwolf`. `import ironfang_renderwolf` and the old
|
|
24
|
+
`Renderwolf` and `RenderwolfError` names still work with a deprecation
|
|
25
|
+
warning, and webhooks still carry the `Renderwolf-*` headers.
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
pip install ironfang-render
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import os
|
|
33
|
+
from ironfang_render import IronfangRender
|
|
34
|
+
|
|
35
|
+
with IronfangRender(api_key=os.environ["IRONFANG_API_KEY"]) as rw:
|
|
36
|
+
shot = rw.screenshot(url="https://example.com", full_page=True)
|
|
37
|
+
shot.save("page.png")
|
|
38
|
+
print(shot.credits, shot.render_ms, shot.request_id)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Every render returns the bytes plus what the API reports about them: content
|
|
42
|
+
type, credits charged (zero on a cache hit), render time and the request id
|
|
43
|
+
to quote to support. Long outputs stream: `save()` and `iter_bytes()` never
|
|
44
|
+
hold a whole clip in memory.
|
|
45
|
+
|
|
46
|
+
## Change the page before capture
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
rw.screenshot(
|
|
50
|
+
html="<div class='chat-widget'>Chat</div><details id='details'><summary id='show-more'>Pricing</summary><p>Expanded pricing</p></details>",
|
|
51
|
+
css=".chat-widget { display: none !important; }",
|
|
52
|
+
script="document.querySelector('#show-more').textContent = 'Show pricing';",
|
|
53
|
+
actions=[
|
|
54
|
+
{"type": "click", "selector": "#show-more"},
|
|
55
|
+
{"type": "wait_for_selector", "selector": "#details[open]"},
|
|
56
|
+
],
|
|
57
|
+
).save("expanded.png")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The same options work with `rw.pdf()` and URL input. After normal load/waits,
|
|
61
|
+
Ironfang Render applies CSS, awaits the script, then executes actions in order
|
|
62
|
+
before settling and capture. JavaScript runs in the target page context as an
|
|
63
|
+
async function body; use `await` or return a promise for asynchronous work. It
|
|
64
|
+
has a five-second limit (or the remaining render timeout, if shorter).
|
|
65
|
+
|
|
66
|
+
CSS is limited to 64 KiB of UTF-8, script to 16 KiB, and actions to 20. Actions
|
|
67
|
+
are `click`, `hover`, `wait_for_selector` (presence), and `delay` with
|
|
68
|
+
`duration_ms`; combined delays may total at most 10,000 ms. Selectors are limited
|
|
69
|
+
to 1,024 UTF-8 bytes and share the render timeout. Invalid input returns
|
|
70
|
+
`bad_request`; script/action execution errors return sanitized `render_failed`
|
|
71
|
+
messages. A screenshot cache hit does not re-execute controls; use `no_cache`
|
|
72
|
+
for fresh execution. [Full reference](https://ironfang.com/render/docs#page-controls).
|
|
73
|
+
|
|
74
|
+
## Durable jobs
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
job = rw.submit_job("pdf", {"html": invoice_html, "paper_format": "a4"}, idempotency_key=f"invoice-{invoice.id}")
|
|
78
|
+
done = rw.wait_for_job(job["id"])
|
|
79
|
+
if done["status"] == "succeeded":
|
|
80
|
+
rw.job_result(job["id"]).save(f"invoice-{invoice.id}.pdf")
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Verifying a webhook
|
|
84
|
+
|
|
85
|
+
Run the check over the raw request body, before parsing it.
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from ironfang_render import verify_webhook
|
|
89
|
+
|
|
90
|
+
ok = verify_webhook(
|
|
91
|
+
secret=os.environ["RENDER_WEBHOOK_SECRET"],
|
|
92
|
+
timestamp=request.headers["Renderwolf-Timestamp"],
|
|
93
|
+
signature=request.headers["Renderwolf-Signature"],
|
|
94
|
+
payload=request.get_data(),
|
|
95
|
+
)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Errors
|
|
99
|
+
|
|
100
|
+
Failures raise `IronfangRenderError` with `status`, the API's `code`
|
|
101
|
+
(`quota_exhausted`, `render_failed`, ...) and `request_id`. Synchronous
|
|
102
|
+
renders are never retried by the client: a render that timed out may already
|
|
103
|
+
have been charged, and retrying it blind doubles the bill.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# ironfang-render
|
|
2
|
+
|
|
3
|
+
The official Python client for the [Ironfang Render](https://ironfang.com/render)
|
|
4
|
+
API: screenshots, PDFs, QR codes, template images, clips and site previews
|
|
5
|
+
from a URL or from HTML you send. No dependencies; Python 3.10 and later.
|
|
6
|
+
|
|
7
|
+
Formerly `ironfang-renderwolf`. `import ironfang_renderwolf` and the old
|
|
8
|
+
`Renderwolf` and `RenderwolfError` names still work with a deprecation
|
|
9
|
+
warning, and webhooks still carry the `Renderwolf-*` headers.
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
pip install ironfang-render
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import os
|
|
17
|
+
from ironfang_render import IronfangRender
|
|
18
|
+
|
|
19
|
+
with IronfangRender(api_key=os.environ["IRONFANG_API_KEY"]) as rw:
|
|
20
|
+
shot = rw.screenshot(url="https://example.com", full_page=True)
|
|
21
|
+
shot.save("page.png")
|
|
22
|
+
print(shot.credits, shot.render_ms, shot.request_id)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Every render returns the bytes plus what the API reports about them: content
|
|
26
|
+
type, credits charged (zero on a cache hit), render time and the request id
|
|
27
|
+
to quote to support. Long outputs stream: `save()` and `iter_bytes()` never
|
|
28
|
+
hold a whole clip in memory.
|
|
29
|
+
|
|
30
|
+
## Change the page before capture
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
rw.screenshot(
|
|
34
|
+
html="<div class='chat-widget'>Chat</div><details id='details'><summary id='show-more'>Pricing</summary><p>Expanded pricing</p></details>",
|
|
35
|
+
css=".chat-widget { display: none !important; }",
|
|
36
|
+
script="document.querySelector('#show-more').textContent = 'Show pricing';",
|
|
37
|
+
actions=[
|
|
38
|
+
{"type": "click", "selector": "#show-more"},
|
|
39
|
+
{"type": "wait_for_selector", "selector": "#details[open]"},
|
|
40
|
+
],
|
|
41
|
+
).save("expanded.png")
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The same options work with `rw.pdf()` and URL input. After normal load/waits,
|
|
45
|
+
Ironfang Render applies CSS, awaits the script, then executes actions in order
|
|
46
|
+
before settling and capture. JavaScript runs in the target page context as an
|
|
47
|
+
async function body; use `await` or return a promise for asynchronous work. It
|
|
48
|
+
has a five-second limit (or the remaining render timeout, if shorter).
|
|
49
|
+
|
|
50
|
+
CSS is limited to 64 KiB of UTF-8, script to 16 KiB, and actions to 20. Actions
|
|
51
|
+
are `click`, `hover`, `wait_for_selector` (presence), and `delay` with
|
|
52
|
+
`duration_ms`; combined delays may total at most 10,000 ms. Selectors are limited
|
|
53
|
+
to 1,024 UTF-8 bytes and share the render timeout. Invalid input returns
|
|
54
|
+
`bad_request`; script/action execution errors return sanitized `render_failed`
|
|
55
|
+
messages. A screenshot cache hit does not re-execute controls; use `no_cache`
|
|
56
|
+
for fresh execution. [Full reference](https://ironfang.com/render/docs#page-controls).
|
|
57
|
+
|
|
58
|
+
## Durable jobs
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
job = rw.submit_job("pdf", {"html": invoice_html, "paper_format": "a4"}, idempotency_key=f"invoice-{invoice.id}")
|
|
62
|
+
done = rw.wait_for_job(job["id"])
|
|
63
|
+
if done["status"] == "succeeded":
|
|
64
|
+
rw.job_result(job["id"]).save(f"invoice-{invoice.id}.pdf")
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Verifying a webhook
|
|
68
|
+
|
|
69
|
+
Run the check over the raw request body, before parsing it.
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from ironfang_render import verify_webhook
|
|
73
|
+
|
|
74
|
+
ok = verify_webhook(
|
|
75
|
+
secret=os.environ["RENDER_WEBHOOK_SECRET"],
|
|
76
|
+
timestamp=request.headers["Renderwolf-Timestamp"],
|
|
77
|
+
signature=request.headers["Renderwolf-Signature"],
|
|
78
|
+
payload=request.get_data(),
|
|
79
|
+
)
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Errors
|
|
83
|
+
|
|
84
|
+
Failures raise `IronfangRenderError` with `status`, the API's `code`
|
|
85
|
+
(`quota_exhausted`, `render_failed`, ...) and `request_id`. Synchronous
|
|
86
|
+
renders are never retried by the client: a render that timed out may already
|
|
87
|
+
have been charged, and retrying it blind doubles the bill.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ironfang-render"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Ironfang Render API client: screenshots, PDFs, QR codes, template images, clips and site previews from a URL or HTML."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "Ironfang Ltd" }]
|
|
13
|
+
keywords = ["screenshot", "pdf", "qr", "html-to-image", "ironfang"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
19
|
+
]
|
|
20
|
+
dependencies = []
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://ironfang.com/render"
|
|
24
|
+
Documentation = "https://ironfang.com/render/docs"
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.packages.find]
|
|
27
|
+
where = ["src"]
|
|
28
|
+
|
|
29
|
+
[tool.pytest.ini_options]
|
|
30
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Ironfang Render API client.
|
|
2
|
+
|
|
3
|
+
from ironfang_render import IronfangRender
|
|
4
|
+
|
|
5
|
+
with IronfangRender(api_key=os.environ["IRONFANG_API_KEY"]) as rw:
|
|
6
|
+
shot = rw.screenshot(url="https://example.com", full_page=True)
|
|
7
|
+
shot.save("page.png")
|
|
8
|
+
|
|
9
|
+
Every render returns the bytes plus what the API reports about them. Errors
|
|
10
|
+
raise IronfangRenderError with the HTTP status, the API's error code and the
|
|
11
|
+
request id to quote to support. Synchronous renders are never retried by the
|
|
12
|
+
client: a render that timed out may already have been charged.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .client import IronfangRender, RenderResult, SitePreviewResult, VERSION
|
|
16
|
+
from .errors import IronfangRenderError
|
|
17
|
+
from .webhook import sign_webhook, verify_webhook
|
|
18
|
+
|
|
19
|
+
# Deprecated names from ironfang-renderwolf, kept so existing code runs unchanged.
|
|
20
|
+
Renderwolf = IronfangRender
|
|
21
|
+
RenderwolfError = IronfangRenderError
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"IronfangRender",
|
|
25
|
+
"RenderResult",
|
|
26
|
+
"SitePreviewResult",
|
|
27
|
+
"IronfangRenderError",
|
|
28
|
+
"Renderwolf",
|
|
29
|
+
"RenderwolfError",
|
|
30
|
+
"sign_webhook",
|
|
31
|
+
"verify_webhook",
|
|
32
|
+
"VERSION",
|
|
33
|
+
]
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import socket
|
|
6
|
+
import time
|
|
7
|
+
import urllib.error
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from email.message import Message
|
|
12
|
+
from email.parser import BytesParser
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, BinaryIO, Iterator
|
|
15
|
+
|
|
16
|
+
from .errors import IronfangRenderError
|
|
17
|
+
|
|
18
|
+
VERSION = "1.0.0"
|
|
19
|
+
DEFAULT_BASE_URL = "https://api.ironfang.com/render"
|
|
20
|
+
TERMINAL = {"succeeded", "failed", "cancelled"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class RenderResult:
|
|
25
|
+
"""A finished render with the facts the API reports about it."""
|
|
26
|
+
|
|
27
|
+
content_type: str
|
|
28
|
+
credits: int
|
|
29
|
+
render_ms: int
|
|
30
|
+
cached: bool
|
|
31
|
+
request_id: str
|
|
32
|
+
headers: dict[str, str]
|
|
33
|
+
_response: Any = field(repr=False)
|
|
34
|
+
_bytes: bytes | None = field(default=None, repr=False)
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def bytes(self) -> bytes:
|
|
38
|
+
"""The whole output. Read once and kept; prefer ``save`` or
|
|
39
|
+
``iter_bytes`` for clips and long PDFs."""
|
|
40
|
+
if self._bytes is None:
|
|
41
|
+
try:
|
|
42
|
+
self._bytes = self._response.read()
|
|
43
|
+
finally:
|
|
44
|
+
self.close()
|
|
45
|
+
return self._bytes
|
|
46
|
+
|
|
47
|
+
def iter_bytes(self, chunk_size: int = 64 * 1024) -> Iterator[bytes]:
|
|
48
|
+
"""Stream the output without holding it all in memory."""
|
|
49
|
+
if self._bytes is not None:
|
|
50
|
+
yield self._bytes
|
|
51
|
+
return
|
|
52
|
+
try:
|
|
53
|
+
while True:
|
|
54
|
+
chunk = self._response.read(chunk_size)
|
|
55
|
+
if not chunk:
|
|
56
|
+
break
|
|
57
|
+
yield chunk
|
|
58
|
+
finally:
|
|
59
|
+
self.close()
|
|
60
|
+
|
|
61
|
+
def save(self, path: str | Path) -> Path:
|
|
62
|
+
"""Stream the output to a file and return its path."""
|
|
63
|
+
target = Path(path)
|
|
64
|
+
with target.open("wb") as f:
|
|
65
|
+
self.write_to(f)
|
|
66
|
+
return target
|
|
67
|
+
|
|
68
|
+
def write_to(self, stream: BinaryIO) -> None:
|
|
69
|
+
if self._bytes is not None:
|
|
70
|
+
stream.write(self._bytes)
|
|
71
|
+
return
|
|
72
|
+
try:
|
|
73
|
+
shutil.copyfileobj(self._response, stream)
|
|
74
|
+
finally:
|
|
75
|
+
self.close()
|
|
76
|
+
|
|
77
|
+
def close(self) -> None:
|
|
78
|
+
"""Release the response, including when output is abandoned."""
|
|
79
|
+
self._response.close()
|
|
80
|
+
|
|
81
|
+
def __enter__(self) -> "RenderResult":
|
|
82
|
+
return self
|
|
83
|
+
|
|
84
|
+
def __exit__(self, *exc: object) -> None:
|
|
85
|
+
self.close()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class SitePreviewResult:
|
|
90
|
+
"""A site preview: the poster frame and the scrolling video."""
|
|
91
|
+
|
|
92
|
+
poster: bytes
|
|
93
|
+
video: bytes
|
|
94
|
+
credits: int
|
|
95
|
+
render_ms: int
|
|
96
|
+
output_seconds: float
|
|
97
|
+
cached: bool
|
|
98
|
+
request_id: str
|
|
99
|
+
headers: dict[str, str]
|
|
100
|
+
|
|
101
|
+
def save(self, video_path: str | Path, poster_path: str | Path | None = None) -> None:
|
|
102
|
+
Path(video_path).write_bytes(self.video)
|
|
103
|
+
if poster_path is not None:
|
|
104
|
+
Path(poster_path).write_bytes(self.poster)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class IronfangRender:
|
|
108
|
+
"""The Ironfang Render client. Use as a context manager or call ``close``."""
|
|
109
|
+
|
|
110
|
+
def __init__(
|
|
111
|
+
self,
|
|
112
|
+
api_key: str,
|
|
113
|
+
*,
|
|
114
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
115
|
+
timeout: float = 90.0,
|
|
116
|
+
opener: urllib.request.OpenerDirector | None = None,
|
|
117
|
+
):
|
|
118
|
+
if not api_key:
|
|
119
|
+
raise IronfangRenderError("an API key is required", status=0, code="missing_api_key")
|
|
120
|
+
self._api_key = api_key
|
|
121
|
+
self._base_url = base_url.rstrip("/")
|
|
122
|
+
self._timeout = timeout
|
|
123
|
+
self._opener = opener or urllib.request.build_opener(_NoRedirect)
|
|
124
|
+
|
|
125
|
+
def __enter__(self) -> "IronfangRender":
|
|
126
|
+
return self
|
|
127
|
+
|
|
128
|
+
def __exit__(self, *exc: object) -> None:
|
|
129
|
+
self.close()
|
|
130
|
+
|
|
131
|
+
def close(self) -> None:
|
|
132
|
+
self._opener.close()
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------- renders
|
|
135
|
+
|
|
136
|
+
def screenshot(self, *, request_id: str | None = None, timeout: float | None = None, **request: Any) -> RenderResult:
|
|
137
|
+
return self._render("/v1/screenshot", request, request_id, timeout)
|
|
138
|
+
|
|
139
|
+
def pdf(self, *, request_id: str | None = None, timeout: float | None = None, **request: Any) -> RenderResult:
|
|
140
|
+
return self._render("/v1/pdf", request, request_id, timeout)
|
|
141
|
+
|
|
142
|
+
def qr(self, *, request_id: str | None = None, timeout: float | None = None, **request: Any) -> RenderResult:
|
|
143
|
+
return self._render("/v1/qr", request, request_id, timeout)
|
|
144
|
+
|
|
145
|
+
def image(self, template_id: str, *, request_id: str | None = None, timeout: float | None = None, **request: Any) -> RenderResult:
|
|
146
|
+
"""Render one of your stored templates with variables filled in."""
|
|
147
|
+
return self._render(f"/v1/image/{urllib.parse.quote(template_id, safe='')}", request, request_id, timeout)
|
|
148
|
+
|
|
149
|
+
def clip(self, *, request_id: str | None = None, timeout: float | None = None, **request: Any) -> RenderResult:
|
|
150
|
+
return self._render("/v1/video", request, request_id, timeout)
|
|
151
|
+
|
|
152
|
+
def site_preview(self, *, request_id: str | None = None, timeout: float | None = None, **request: Any) -> SitePreviewResult:
|
|
153
|
+
resp = self._send("POST", "/v1/site-preview", request, request_id=request_id, timeout=timeout)
|
|
154
|
+
try:
|
|
155
|
+
headers = _headers(resp)
|
|
156
|
+
body = resp.read()
|
|
157
|
+
finally:
|
|
158
|
+
resp.close()
|
|
159
|
+
parts = _multipart(headers.get("content-type", ""), body)
|
|
160
|
+
if "poster" not in parts or "video" not in parts:
|
|
161
|
+
raise IronfangRenderError("the response did not include both preview files", status=200, code="bad_response", request_id=headers.get("x-ironfang-request-id"))
|
|
162
|
+
return SitePreviewResult(
|
|
163
|
+
poster=parts["poster"],
|
|
164
|
+
video=parts["video"],
|
|
165
|
+
credits=_int(headers.get("x-renderwolf-credits")),
|
|
166
|
+
render_ms=_int(headers.get("x-renderwolf-render-ms")),
|
|
167
|
+
output_seconds=float(headers.get("x-renderwolf-output-seconds") or 0),
|
|
168
|
+
cached=headers.get("x-renderwolf-cache") == "hit",
|
|
169
|
+
request_id=headers.get("x-ironfang-request-id", ""),
|
|
170
|
+
headers=headers,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def _render(self, path: str, request: dict[str, Any], request_id: str | None, timeout: float | None) -> RenderResult:
|
|
174
|
+
resp = self._send("POST", path, request, request_id=request_id, timeout=timeout)
|
|
175
|
+
headers = _headers(resp)
|
|
176
|
+
return RenderResult(
|
|
177
|
+
content_type=headers.get("content-type", "application/octet-stream").split(";")[0].strip(),
|
|
178
|
+
credits=_int(headers.get("x-renderwolf-credits")),
|
|
179
|
+
render_ms=_int(headers.get("x-renderwolf-render-ms")),
|
|
180
|
+
cached=headers.get("x-renderwolf-cache") == "hit",
|
|
181
|
+
request_id=headers.get("x-ironfang-request-id", ""),
|
|
182
|
+
headers=headers,
|
|
183
|
+
_response=resp,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# ------------------------------------------------------------------- jobs
|
|
187
|
+
|
|
188
|
+
def submit_job(self, kind: str, request: dict[str, Any], *, external_id: str | None = None, delivery: dict[str, Any] | None = None, idempotency_key: str | None = None) -> dict[str, Any]:
|
|
189
|
+
"""Submit a durable job. ``idempotency_key`` makes a retried submission
|
|
190
|
+
return the same job rather than a second one."""
|
|
191
|
+
body: dict[str, Any] = {"kind": kind, "request": request}
|
|
192
|
+
if external_id:
|
|
193
|
+
body["external_id"] = external_id
|
|
194
|
+
if delivery:
|
|
195
|
+
body["delivery"] = delivery
|
|
196
|
+
extra = {"Idempotency-Key": idempotency_key} if idempotency_key else {}
|
|
197
|
+
return self._json("POST", "/v1/jobs", body, extra=extra)
|
|
198
|
+
|
|
199
|
+
def get_job(self, job_id: str) -> dict[str, Any]:
|
|
200
|
+
return self._json("GET", f"/v1/jobs/{urllib.parse.quote(job_id, safe='')}")
|
|
201
|
+
|
|
202
|
+
def list_jobs(self, *, status: str | None = None, cursor: str | None = None, limit: int | None = None) -> dict[str, Any]:
|
|
203
|
+
return self._json("GET", "/v1/jobs" + _query(status=status, cursor=cursor, limit=limit))
|
|
204
|
+
|
|
205
|
+
def cancel_job(self, job_id: str) -> dict[str, Any]:
|
|
206
|
+
return self._json("DELETE", f"/v1/jobs/{urllib.parse.quote(job_id, safe='')}")
|
|
207
|
+
|
|
208
|
+
def job_result(self, job_id: str) -> RenderResult:
|
|
209
|
+
"""Download a succeeded job's result."""
|
|
210
|
+
resp = self._send("GET", f"/v1/jobs/{urllib.parse.quote(job_id, safe='')}/result", None, follow_redirects=True)
|
|
211
|
+
headers = _headers(resp)
|
|
212
|
+
return RenderResult(
|
|
213
|
+
content_type=headers.get("content-type", "application/octet-stream").split(";")[0].strip(),
|
|
214
|
+
credits=0, render_ms=0, cached=False,
|
|
215
|
+
request_id=headers.get("x-ironfang-request-id", ""),
|
|
216
|
+
headers=headers, _response=resp,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def wait_for_job(self, job_id: str, *, poll_seconds: float = 2.0, timeout: float = 600.0, should_stop: Any = None) -> dict[str, Any]:
|
|
220
|
+
"""Poll until the job is terminal. Returns the job whatever the
|
|
221
|
+
outcome; check ``status``. ``should_stop`` is an optional callable
|
|
222
|
+
returning True to abandon the wait."""
|
|
223
|
+
deadline = time.monotonic() + timeout
|
|
224
|
+
while True:
|
|
225
|
+
job = self.get_job(job_id)
|
|
226
|
+
if job.get("status") in TERMINAL:
|
|
227
|
+
return job
|
|
228
|
+
if should_stop is not None and should_stop():
|
|
229
|
+
raise IronfangRenderError("wait abandoned", status=0, code="aborted")
|
|
230
|
+
if time.monotonic() >= deadline:
|
|
231
|
+
raise IronfangRenderError(f"job {job_id} did not finish within {timeout:g}s", status=0, code="timeout")
|
|
232
|
+
time.sleep(poll_seconds)
|
|
233
|
+
|
|
234
|
+
# ----------------------------------------------------------- destinations
|
|
235
|
+
|
|
236
|
+
def create_destination(self, **destination: Any) -> dict[str, Any]:
|
|
237
|
+
"""Register where finished jobs go. A webhook destination's
|
|
238
|
+
``signing_secret`` is returned here once and never again; keep it."""
|
|
239
|
+
return self._json("POST", "/v1/destinations", destination)
|
|
240
|
+
|
|
241
|
+
def list_destinations(self) -> dict[str, Any]:
|
|
242
|
+
return self._json("GET", "/v1/destinations")
|
|
243
|
+
|
|
244
|
+
def test_destination(self, destination_id: str) -> dict[str, Any]:
|
|
245
|
+
"""Reach the destination now; a failure is reported in the body, not raised."""
|
|
246
|
+
return self._json("POST", f"/v1/destinations/{urllib.parse.quote(destination_id, safe='')}/test")
|
|
247
|
+
|
|
248
|
+
def delete_destination(self, destination_id: str) -> None:
|
|
249
|
+
resp = self._send("DELETE", f"/v1/destinations/{urllib.parse.quote(destination_id, safe='')}", None)
|
|
250
|
+
resp.close()
|
|
251
|
+
|
|
252
|
+
# --------------------------------------------------------------- the rest
|
|
253
|
+
|
|
254
|
+
def sign(self, **request: Any) -> dict[str, Any]:
|
|
255
|
+
"""Mint a signed render URL that an <img> or an email can fetch directly."""
|
|
256
|
+
return self._json("POST", "/v1/sign", request)
|
|
257
|
+
|
|
258
|
+
def usage(self) -> dict[str, Any]:
|
|
259
|
+
return self._json("GET", "/v1/usage")
|
|
260
|
+
|
|
261
|
+
def requests(self, **filters: Any) -> dict[str, Any]:
|
|
262
|
+
"""Request history for the last seven days."""
|
|
263
|
+
return self._json("GET", "/v1/requests" + _query(**filters))
|
|
264
|
+
|
|
265
|
+
# -------------------------------------------------------------- transport
|
|
266
|
+
|
|
267
|
+
def _json(self, method: str, path: str, body: Any = None, *, extra: dict[str, str] | None = None) -> dict[str, Any]:
|
|
268
|
+
resp = self._send(method, path, body, extra=extra)
|
|
269
|
+
try:
|
|
270
|
+
return json.loads(resp.read().decode())
|
|
271
|
+
finally:
|
|
272
|
+
resp.close()
|
|
273
|
+
|
|
274
|
+
def _send(self, method: str, path: str, body: Any, *, request_id: str | None = None, timeout: float | None = None, extra: dict[str, str] | None = None, follow_redirects: bool = False) -> Any:
|
|
275
|
+
headers = {
|
|
276
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
277
|
+
"Accept": "application/json, */*",
|
|
278
|
+
"X-Ironfang-Client": f"ironfang-render-python/{VERSION}",
|
|
279
|
+
}
|
|
280
|
+
if extra:
|
|
281
|
+
headers.update(extra)
|
|
282
|
+
if request_id:
|
|
283
|
+
headers["X-Request-ID"] = request_id
|
|
284
|
+
data = None
|
|
285
|
+
if body is not None:
|
|
286
|
+
headers["Content-Type"] = "application/json"
|
|
287
|
+
data = json.dumps(body).encode()
|
|
288
|
+
url = self._base_url + path
|
|
289
|
+
req = urllib.request.Request(url, data=data, method=method, headers=headers)
|
|
290
|
+
opener = urllib.request.build_opener() if follow_redirects else self._opener
|
|
291
|
+
try:
|
|
292
|
+
# No retry here on purpose: a synchronous render that timed out
|
|
293
|
+
# may have been charged, and retrying it blind doubles the bill.
|
|
294
|
+
return opener.open(req, timeout=timeout or self._timeout)
|
|
295
|
+
except urllib.error.HTTPError as e:
|
|
296
|
+
raise _error_from(e) from None
|
|
297
|
+
except (urllib.error.URLError, socket.timeout, TimeoutError) as e:
|
|
298
|
+
reason = getattr(e, "reason", e)
|
|
299
|
+
if isinstance(reason, (socket.timeout, TimeoutError)) or isinstance(e, (socket.timeout, TimeoutError)):
|
|
300
|
+
raise IronfangRenderError(f"request timed out after {timeout or self._timeout:g}s", status=0, code="timeout") from None
|
|
301
|
+
raise IronfangRenderError(f"network error: {reason}", status=0, code="network_error") from None
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
305
|
+
"""Renders never redirect; a redirect on a render is a misconfiguration
|
|
306
|
+
worth surfacing rather than a hop to follow with the bearer key."""
|
|
307
|
+
|
|
308
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl): # type: ignore[override]
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _error_from(e: urllib.error.HTTPError) -> IronfangRenderError:
|
|
313
|
+
code = f"http_{e.code}"
|
|
314
|
+
message = f"request failed with HTTP {e.code}"
|
|
315
|
+
request_id = e.headers.get("X-Ironfang-Request-ID") if e.headers else None
|
|
316
|
+
try:
|
|
317
|
+
body = json.loads(e.read().decode())
|
|
318
|
+
err = body.get("error") or {}
|
|
319
|
+
code = err.get("code") or code
|
|
320
|
+
message = err.get("message") or message
|
|
321
|
+
request_id = body.get("request_id") or request_id
|
|
322
|
+
except Exception:
|
|
323
|
+
pass
|
|
324
|
+
finally:
|
|
325
|
+
e.close()
|
|
326
|
+
return IronfangRenderError(message, status=e.code, code=code, request_id=request_id)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _headers(resp: Any) -> dict[str, str]:
|
|
330
|
+
return {k.lower(): v for k, v in resp.headers.items()}
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _int(v: str | None) -> int:
|
|
334
|
+
try:
|
|
335
|
+
return int(float(v or 0))
|
|
336
|
+
except ValueError:
|
|
337
|
+
return 0
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _query(**params: Any) -> str:
|
|
341
|
+
clean = {k: v for k, v in params.items() if v not in (None, "")}
|
|
342
|
+
return ("?" + urllib.parse.urlencode(clean)) if clean else ""
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _multipart(content_type: str, body: bytes) -> dict[str, bytes]:
|
|
346
|
+
msg = BytesParser().parsebytes(b"Content-Type: " + content_type.encode() + b"\r\n\r\n" + body)
|
|
347
|
+
parts: dict[str, bytes] = {}
|
|
348
|
+
for part in msg.walk():
|
|
349
|
+
name = part.get_param("name", header="content-disposition")
|
|
350
|
+
if name and not part.is_multipart():
|
|
351
|
+
payload = part.get_payload(decode=True)
|
|
352
|
+
if payload is not None:
|
|
353
|
+
parts[str(name)] = payload
|
|
354
|
+
return parts
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
class IronfangRenderError(Exception):
|
|
2
|
+
"""An error the API returned, or a failure to reach it.
|
|
3
|
+
|
|
4
|
+
``code`` is the API's stable error code (``bad_request``,
|
|
5
|
+
``quota_exhausted``, ``render_failed``, ...) or a client-side one
|
|
6
|
+
(``network_error``, ``timeout``). ``request_id`` is the id to quote to
|
|
7
|
+
support; it is on every response the server produced, including errors.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
def __init__(self, message: str, *, status: int, code: str, request_id: str | None = None):
|
|
11
|
+
super().__init__(message)
|
|
12
|
+
self.status = status
|
|
13
|
+
self.code = code
|
|
14
|
+
self.request_id = request_id
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def is_client_error(self) -> bool:
|
|
18
|
+
return 400 <= self.status < 500
|
|
19
|
+
|
|
20
|
+
def __str__(self) -> str:
|
|
21
|
+
base = super().__str__()
|
|
22
|
+
if self.request_id:
|
|
23
|
+
return f"{base} (request {self.request_id})"
|
|
24
|
+
return base
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Verify an Ironfang Render webhook delivery.
|
|
2
|
+
|
|
3
|
+
Ironfang Render signs each delivery with HMAC-SHA256 over
|
|
4
|
+
``<timestamp>.<raw body>`` using the endpoint's signing secret, and sends it
|
|
5
|
+
as ``Renderwolf-Signature: v1=<hex>`` with ``Renderwolf-Timestamp`` beside it.
|
|
6
|
+
Run the check over the raw bytes as received: re-serialising parsed JSON
|
|
7
|
+
changes them and the signature will not match.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import hmac
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def sign_webhook(secret: str, timestamp: str, payload: bytes | str) -> str:
|
|
18
|
+
"""Compute the signature Ironfang Render would send."""
|
|
19
|
+
body = payload.encode() if isinstance(payload, str) else payload
|
|
20
|
+
mac = hmac.new(secret.encode(), (timestamp + ".").encode() + body, hashlib.sha256)
|
|
21
|
+
return "v1=" + mac.hexdigest()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def verify_webhook(
|
|
25
|
+
*,
|
|
26
|
+
secret: str,
|
|
27
|
+
timestamp: str,
|
|
28
|
+
signature: str,
|
|
29
|
+
payload: bytes | str,
|
|
30
|
+
tolerance_seconds: int = 300,
|
|
31
|
+
now: float | None = None,
|
|
32
|
+
) -> bool:
|
|
33
|
+
"""Return True only for a genuine, recent delivery.
|
|
34
|
+
|
|
35
|
+
``tolerance_seconds`` rejects a delivery whose timestamp is further than
|
|
36
|
+
that from now, so a captured delivery cannot be replayed later. Pass 0 to
|
|
37
|
+
skip the check, for tests with fixed timestamps.
|
|
38
|
+
"""
|
|
39
|
+
if tolerance_seconds > 0:
|
|
40
|
+
try:
|
|
41
|
+
ts = float(timestamp)
|
|
42
|
+
except (TypeError, ValueError):
|
|
43
|
+
return False
|
|
44
|
+
current = time.time() if now is None else now
|
|
45
|
+
if abs(current - ts) > tolerance_seconds:
|
|
46
|
+
return False
|
|
47
|
+
expected = sign_webhook(secret, timestamp, payload)
|
|
48
|
+
return hmac.compare_digest(expected, signature.strip())
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ironfang-render
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Ironfang Render API client: screenshots, PDFs, QR codes, template images, clips and site previews from a URL or HTML.
|
|
5
|
+
Author: Ironfang Ltd
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://ironfang.com/render
|
|
8
|
+
Project-URL: Documentation, https://ironfang.com/render/docs
|
|
9
|
+
Keywords: screenshot,pdf,qr,html-to-image,ironfang
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# ironfang-render
|
|
18
|
+
|
|
19
|
+
The official Python client for the [Ironfang Render](https://ironfang.com/render)
|
|
20
|
+
API: screenshots, PDFs, QR codes, template images, clips and site previews
|
|
21
|
+
from a URL or from HTML you send. No dependencies; Python 3.10 and later.
|
|
22
|
+
|
|
23
|
+
Formerly `ironfang-renderwolf`. `import ironfang_renderwolf` and the old
|
|
24
|
+
`Renderwolf` and `RenderwolfError` names still work with a deprecation
|
|
25
|
+
warning, and webhooks still carry the `Renderwolf-*` headers.
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
pip install ironfang-render
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import os
|
|
33
|
+
from ironfang_render import IronfangRender
|
|
34
|
+
|
|
35
|
+
with IronfangRender(api_key=os.environ["IRONFANG_API_KEY"]) as rw:
|
|
36
|
+
shot = rw.screenshot(url="https://example.com", full_page=True)
|
|
37
|
+
shot.save("page.png")
|
|
38
|
+
print(shot.credits, shot.render_ms, shot.request_id)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Every render returns the bytes plus what the API reports about them: content
|
|
42
|
+
type, credits charged (zero on a cache hit), render time and the request id
|
|
43
|
+
to quote to support. Long outputs stream: `save()` and `iter_bytes()` never
|
|
44
|
+
hold a whole clip in memory.
|
|
45
|
+
|
|
46
|
+
## Change the page before capture
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
rw.screenshot(
|
|
50
|
+
html="<div class='chat-widget'>Chat</div><details id='details'><summary id='show-more'>Pricing</summary><p>Expanded pricing</p></details>",
|
|
51
|
+
css=".chat-widget { display: none !important; }",
|
|
52
|
+
script="document.querySelector('#show-more').textContent = 'Show pricing';",
|
|
53
|
+
actions=[
|
|
54
|
+
{"type": "click", "selector": "#show-more"},
|
|
55
|
+
{"type": "wait_for_selector", "selector": "#details[open]"},
|
|
56
|
+
],
|
|
57
|
+
).save("expanded.png")
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The same options work with `rw.pdf()` and URL input. After normal load/waits,
|
|
61
|
+
Ironfang Render applies CSS, awaits the script, then executes actions in order
|
|
62
|
+
before settling and capture. JavaScript runs in the target page context as an
|
|
63
|
+
async function body; use `await` or return a promise for asynchronous work. It
|
|
64
|
+
has a five-second limit (or the remaining render timeout, if shorter).
|
|
65
|
+
|
|
66
|
+
CSS is limited to 64 KiB of UTF-8, script to 16 KiB, and actions to 20. Actions
|
|
67
|
+
are `click`, `hover`, `wait_for_selector` (presence), and `delay` with
|
|
68
|
+
`duration_ms`; combined delays may total at most 10,000 ms. Selectors are limited
|
|
69
|
+
to 1,024 UTF-8 bytes and share the render timeout. Invalid input returns
|
|
70
|
+
`bad_request`; script/action execution errors return sanitized `render_failed`
|
|
71
|
+
messages. A screenshot cache hit does not re-execute controls; use `no_cache`
|
|
72
|
+
for fresh execution. [Full reference](https://ironfang.com/render/docs#page-controls).
|
|
73
|
+
|
|
74
|
+
## Durable jobs
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
job = rw.submit_job("pdf", {"html": invoice_html, "paper_format": "a4"}, idempotency_key=f"invoice-{invoice.id}")
|
|
78
|
+
done = rw.wait_for_job(job["id"])
|
|
79
|
+
if done["status"] == "succeeded":
|
|
80
|
+
rw.job_result(job["id"]).save(f"invoice-{invoice.id}.pdf")
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Verifying a webhook
|
|
84
|
+
|
|
85
|
+
Run the check over the raw request body, before parsing it.
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from ironfang_render import verify_webhook
|
|
89
|
+
|
|
90
|
+
ok = verify_webhook(
|
|
91
|
+
secret=os.environ["RENDER_WEBHOOK_SECRET"],
|
|
92
|
+
timestamp=request.headers["Renderwolf-Timestamp"],
|
|
93
|
+
signature=request.headers["Renderwolf-Signature"],
|
|
94
|
+
payload=request.get_data(),
|
|
95
|
+
)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Errors
|
|
99
|
+
|
|
100
|
+
Failures raise `IronfangRenderError` with `status`, the API's `code`
|
|
101
|
+
(`quota_exhausted`, `render_failed`, ...) and `request_id`. Synchronous
|
|
102
|
+
renders are never retried by the client: a render that timed out may already
|
|
103
|
+
have been charged, and retrying it blind doubles the bill.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/ironfang_render/__init__.py
|
|
4
|
+
src/ironfang_render/client.py
|
|
5
|
+
src/ironfang_render/errors.py
|
|
6
|
+
src/ironfang_render/webhook.py
|
|
7
|
+
src/ironfang_render.egg-info/PKG-INFO
|
|
8
|
+
src/ironfang_render.egg-info/SOURCES.txt
|
|
9
|
+
src/ironfang_render.egg-info/dependency_links.txt
|
|
10
|
+
src/ironfang_render.egg-info/top_level.txt
|
|
11
|
+
src/ironfang_renderwolf/__init__.py
|
|
12
|
+
tests/test_client.py
|
|
13
|
+
tests/test_compat.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Deprecated: the package is now ironfang_render.
|
|
2
|
+
|
|
3
|
+
Importing ironfang_renderwolf still works and gives the same objects, so
|
|
4
|
+
existing code keeps running. Change imports to ironfang_render.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
import warnings
|
|
9
|
+
|
|
10
|
+
warnings.warn(
|
|
11
|
+
"ironfang_renderwolf is deprecated; import ironfang_render instead",
|
|
12
|
+
DeprecationWarning,
|
|
13
|
+
stacklevel=2,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
import ironfang_render
|
|
17
|
+
from ironfang_render import * # noqa: F401,F403
|
|
18
|
+
from ironfang_render import __all__ # noqa: F401
|
|
19
|
+
from ironfang_render import client, errors, webhook
|
|
20
|
+
|
|
21
|
+
for _name in ("client", "errors", "webhook"):
|
|
22
|
+
sys.modules[f"{__name__}.{_name}"] = getattr(ironfang_render, _name)
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import threading
|
|
3
|
+
import time
|
|
4
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from ironfang_render import IronfangRender, IronfangRenderError, sign_webhook, verify_webhook
|
|
9
|
+
|
|
10
|
+
SEEN: list[dict] = []
|
|
11
|
+
STATE = {"polls": 0, "hang": False}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Fake(BaseHTTPRequestHandler):
|
|
15
|
+
def log_message(self, *a): # quiet
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
def _reply(self, status, body: bytes, headers=None):
|
|
19
|
+
self.send_response(status)
|
|
20
|
+
self.send_header("X-Ironfang-Request-ID", "req-fixture")
|
|
21
|
+
for k, v in (headers or {}).items():
|
|
22
|
+
self.send_header(k, v)
|
|
23
|
+
self.send_header("Content-Length", str(len(body)))
|
|
24
|
+
self.end_headers()
|
|
25
|
+
self.wfile.write(body)
|
|
26
|
+
|
|
27
|
+
def do_POST(self):
|
|
28
|
+
raw = self.rfile.read(int(self.headers.get("Content-Length") or 0)).decode()
|
|
29
|
+
SEEN.append({"method": "POST", "path": self.path, "headers": {k.lower(): v for k, v in self.headers.items()}, "body": raw})
|
|
30
|
+
if STATE["hang"]:
|
|
31
|
+
time.sleep(2)
|
|
32
|
+
return
|
|
33
|
+
if self.path == "/v1/screenshot":
|
|
34
|
+
if "broken.example" in raw:
|
|
35
|
+
return self._reply(422, json.dumps({"error": {"code": "render_failed", "message": "navigate: context deadline exceeded"}, "request_id": "req-fixture"}).encode(), {"Content-Type": "application/json"})
|
|
36
|
+
return self._reply(200, bytes([137, 80, 78, 71]), {"Content-Type": "image/png", "X-Renderwolf-Credits": "1", "X-Renderwolf-Render-Ms": "812", "X-Renderwolf-Cache": "miss"})
|
|
37
|
+
if self.path == "/v1/qr":
|
|
38
|
+
return self._reply(200, bytes([137, 80, 78, 71]), {"Content-Type": "image/png", "X-Renderwolf-Credits": "0", "X-Renderwolf-Cache": "hit"})
|
|
39
|
+
if self.path == "/v1/jobs":
|
|
40
|
+
return self._reply(202, json.dumps({"id": "job-1", "kind": "pdf", "status": "queued"}).encode(), {"Content-Type": "application/json"})
|
|
41
|
+
if self.path == "/v1/site-preview":
|
|
42
|
+
b = b"xx"
|
|
43
|
+
body = (b"--" + b + b"\r\nContent-Disposition: form-data; name=\"poster\"; filename=\"poster.jpg\"\r\nContent-Type: image/jpeg\r\n\r\nJPEG\r\n"
|
|
44
|
+
b"--" + b + b"\r\nContent-Disposition: form-data; name=\"video\"; filename=\"preview.mp4\"\r\nContent-Type: video/mp4\r\n\r\nMP4DATA\r\n--" + b + b"--\r\n")
|
|
45
|
+
return self._reply(200, body, {"Content-Type": "multipart/form-data; boundary=xx", "X-Renderwolf-Credits": "6", "X-Renderwolf-Output-Seconds": "6"})
|
|
46
|
+
self._reply(404, json.dumps({"error": {"code": "not_found", "message": "no such route"}}).encode(), {"Content-Type": "application/json"})
|
|
47
|
+
|
|
48
|
+
def do_GET(self):
|
|
49
|
+
SEEN.append({"method": "GET", "path": self.path, "headers": {k.lower(): v for k, v in self.headers.items()}, "body": ""})
|
|
50
|
+
if self.path == "/v1/jobs/job-1":
|
|
51
|
+
STATE["polls"] += 1
|
|
52
|
+
status = "running" if STATE["polls"] < 3 else "succeeded"
|
|
53
|
+
return self._reply(200, json.dumps({"id": "job-1", "status": status}).encode(), {"Content-Type": "application/json"})
|
|
54
|
+
if self.path == "/v1/jobs/job-1/result":
|
|
55
|
+
return self._reply(200, b"%PDF-1.7\n", {"Content-Type": "application/pdf"})
|
|
56
|
+
if self.path.startswith("/v1/requests"):
|
|
57
|
+
return self._reply(200, json.dumps({"requests": [{"id": "r1"}], "retention_days": 7}).encode(), {"Content-Type": "application/json"})
|
|
58
|
+
if self.path == "/v1/nope":
|
|
59
|
+
return self._reply(401, b"not json")
|
|
60
|
+
self._reply(404, json.dumps({"error": {"code": "not_found", "message": "no"}}).encode(), {"Content-Type": "application/json"})
|
|
61
|
+
|
|
62
|
+
def do_DELETE(self):
|
|
63
|
+
self.do_GET()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.fixture(scope="module")
|
|
67
|
+
def base_url():
|
|
68
|
+
server = HTTPServer(("127.0.0.1", 0), Fake)
|
|
69
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
70
|
+
thread.start()
|
|
71
|
+
yield f"http://127.0.0.1:{server.server_port}"
|
|
72
|
+
server.shutdown()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.fixture
|
|
76
|
+
def rw(base_url):
|
|
77
|
+
with IronfangRender("rw_test_secret_key", base_url=base_url, timeout=5) as client:
|
|
78
|
+
yield client
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_render_sends_key_and_returns_facts(rw, tmp_path):
|
|
82
|
+
SEEN.clear()
|
|
83
|
+
request = {
|
|
84
|
+
"url": "https://acme.example", "full_page": True,
|
|
85
|
+
"css": ".chat-widget { display: none !important; }",
|
|
86
|
+
"script": "document.querySelector('#pricing')?.click();",
|
|
87
|
+
"actions": [{"type": "click", "selector": "#expand"}, {"type": "delay", "duration_ms": 100}],
|
|
88
|
+
}
|
|
89
|
+
out = rw.screenshot(**request, request_id="order-77")
|
|
90
|
+
req = SEEN[0]
|
|
91
|
+
assert req["headers"]["authorization"] == "Bearer rw_test_secret_key"
|
|
92
|
+
assert req["headers"]["x-ironfang-client"].startswith("ironfang-render-python/")
|
|
93
|
+
assert req["headers"]["x-request-id"] == "order-77"
|
|
94
|
+
assert json.loads(req["body"]) == request
|
|
95
|
+
assert out.content_type == "image/png"
|
|
96
|
+
assert out.credits == 1 and out.render_ms == 812 and out.cached is False
|
|
97
|
+
assert out.request_id == "req-fixture"
|
|
98
|
+
saved = out.save(tmp_path / "page.png")
|
|
99
|
+
assert saved.read_bytes() == bytes([137, 80, 78, 71])
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_cache_hit_is_free(rw):
|
|
103
|
+
out = rw.qr(data="https://acme.example")
|
|
104
|
+
assert out.cached is True and out.credits == 0
|
|
105
|
+
assert out.bytes[:1] == b"\x89"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_streaming_does_not_need_the_whole_body_first(rw):
|
|
109
|
+
out = rw.screenshot(url="https://acme.example")
|
|
110
|
+
chunks = list(out.iter_bytes(chunk_size=2))
|
|
111
|
+
assert b"".join(chunks) == bytes([137, 80, 78, 71])
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_api_error_is_typed_and_never_carries_the_key(rw):
|
|
115
|
+
with pytest.raises(IronfangRenderError) as info:
|
|
116
|
+
rw.screenshot(url="https://broken.example")
|
|
117
|
+
err = info.value
|
|
118
|
+
assert err.status == 422 and err.code == "render_failed" and err.request_id == "req-fixture"
|
|
119
|
+
assert "deadline" in str(err) and "req-fixture" in str(err)
|
|
120
|
+
assert "rw_test_secret_key" not in str(err)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def test_non_json_error_body(rw):
|
|
124
|
+
with pytest.raises(IronfangRenderError) as info:
|
|
125
|
+
rw._json("GET", "/v1/nope")
|
|
126
|
+
assert info.value.status == 401 and info.value.code == "http_401"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_timeout_makes_exactly_one_attempt(rw):
|
|
130
|
+
STATE["hang"] = True
|
|
131
|
+
SEEN.clear()
|
|
132
|
+
try:
|
|
133
|
+
with pytest.raises(IronfangRenderError) as info:
|
|
134
|
+
rw.screenshot(url="https://slow.example", timeout=0.3)
|
|
135
|
+
assert info.value.code == "timeout"
|
|
136
|
+
assert len([s for s in SEEN if s["path"] == "/v1/screenshot"]) == 1
|
|
137
|
+
finally:
|
|
138
|
+
STATE["hang"] = False
|
|
139
|
+
time.sleep(2.1) # let the hung handler finish before the next test
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_jobs_submit_wait_and_collect(rw):
|
|
143
|
+
STATE["polls"] = 0
|
|
144
|
+
SEEN.clear()
|
|
145
|
+
job = rw.submit_job("pdf", {"html": "<h1>Invoice</h1>"}, idempotency_key="inv-1042")
|
|
146
|
+
assert job["status"] == "queued"
|
|
147
|
+
assert SEEN[0]["headers"]["idempotency-key"] == "inv-1042"
|
|
148
|
+
done = rw.wait_for_job("job-1", poll_seconds=0.01)
|
|
149
|
+
assert done["status"] == "succeeded" and STATE["polls"] == 3
|
|
150
|
+
result = rw.job_result("job-1")
|
|
151
|
+
assert result.content_type == "application/pdf" and result.bytes.startswith(b"%PDF")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_site_preview_splits_the_multipart(rw):
|
|
155
|
+
out = rw.site_preview(url="https://acme.example")
|
|
156
|
+
assert out.poster == b"JPEG" and out.video == b"MP4DATA"
|
|
157
|
+
assert out.credits == 6 and out.output_seconds == 6.0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_history_filters(rw):
|
|
161
|
+
SEEN.clear()
|
|
162
|
+
out = rw.requests(outcome="error", kind="qr", limit=10)
|
|
163
|
+
assert SEEN[0]["path"] == "/v1/requests?outcome=error&kind=qr&limit=10"
|
|
164
|
+
assert out["retention_days"] == 7
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_refuses_to_start_without_a_key(base_url):
|
|
168
|
+
with pytest.raises(IronfangRenderError) as info:
|
|
169
|
+
IronfangRender("", base_url=base_url)
|
|
170
|
+
assert info.value.code == "missing_api_key"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_default_base_is_the_canonical_one():
|
|
174
|
+
import io
|
|
175
|
+
|
|
176
|
+
class Opener:
|
|
177
|
+
urls: list[str] = []
|
|
178
|
+
|
|
179
|
+
def open(self, req, timeout=None):
|
|
180
|
+
self.urls.append(req.full_url)
|
|
181
|
+
return io.BytesIO(b"{}")
|
|
182
|
+
|
|
183
|
+
opener = Opener()
|
|
184
|
+
IronfangRender("rw_test_secret_key", opener=opener).usage()
|
|
185
|
+
# The launch-era /renderwolf prefix still answers, so a caller who set it stays where they are.
|
|
186
|
+
IronfangRender("rw_test_secret_key", base_url="https://api.ironfang.com/renderwolf/", opener=opener).usage()
|
|
187
|
+
assert opener.urls == ["https://api.ironfang.com/render/v1/usage", "https://api.ironfang.com/renderwolf/v1/usage"]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def test_webhook_matches_the_go_implementation():
|
|
191
|
+
# Literal produced by internal/webhook.Signature([]byte("secret"), "1700000000", []byte("hello")).
|
|
192
|
+
assert sign_webhook("secret", "1700000000", "hello") == "v1=47b1df0ab12338b2685470b0d2b37033add7c3b2bc8172f313e77413f1bb78c8"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_webhook_verification_rules():
|
|
196
|
+
secret, ts = "whsec_test", "1756480271"
|
|
197
|
+
payload = b'{"type":"render.job.succeeded","job":{"id":"job-1"}}'
|
|
198
|
+
sig = sign_webhook(secret, ts, payload)
|
|
199
|
+
assert verify_webhook(secret=secret, timestamp=ts, signature=sig, payload=payload, tolerance_seconds=0)
|
|
200
|
+
assert not verify_webhook(secret="other", timestamp=ts, signature=sig, payload=payload, tolerance_seconds=0)
|
|
201
|
+
assert not verify_webhook(secret=secret, timestamp=ts, signature=sig, payload=payload.replace(b"job-1", b"job-2"), tolerance_seconds=0)
|
|
202
|
+
# Raw bytes matter: the same JSON re-serialised must fail.
|
|
203
|
+
pretty = json.dumps(json.loads(payload), indent=2).encode()
|
|
204
|
+
assert not verify_webhook(secret=secret, timestamp=ts, signature=sig, payload=pretty, tolerance_seconds=0)
|
|
205
|
+
assert not verify_webhook(secret=secret, timestamp=ts, signature=sig, payload=payload, now=int(ts) + 3600)
|
|
206
|
+
assert verify_webhook(secret=secret, timestamp=ts, signature=sig, payload=payload, now=int(ts) + 60)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@pytest.mark.parametrize("method", ["bytes", "write_to", "iter_bytes"])
|
|
210
|
+
def test_result_closes_after_failed_read(method):
|
|
211
|
+
from ironfang_render.client import RenderResult
|
|
212
|
+
import io
|
|
213
|
+
|
|
214
|
+
class BrokenResponse:
|
|
215
|
+
closed = False
|
|
216
|
+
def read(self, *args):
|
|
217
|
+
raise OSError("read failed")
|
|
218
|
+
def close(self):
|
|
219
|
+
self.closed = True
|
|
220
|
+
|
|
221
|
+
response = BrokenResponse()
|
|
222
|
+
result = RenderResult("image/png", 0, 0, False, "fixture", {}, response)
|
|
223
|
+
with pytest.raises(OSError, match="read failed"):
|
|
224
|
+
if method == "bytes":
|
|
225
|
+
result.bytes
|
|
226
|
+
elif method == "write_to":
|
|
227
|
+
result.write_to(io.BytesIO())
|
|
228
|
+
else:
|
|
229
|
+
list(result.iter_bytes())
|
|
230
|
+
assert response.closed
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_result_closes_after_failed_write_and_when_abandoned():
|
|
234
|
+
from ironfang_render.client import RenderResult
|
|
235
|
+
import io
|
|
236
|
+
|
|
237
|
+
class BrokenWriter:
|
|
238
|
+
def write(self, data):
|
|
239
|
+
raise OSError("write failed")
|
|
240
|
+
|
|
241
|
+
response = io.BytesIO(b"render")
|
|
242
|
+
result = RenderResult("image/png", 0, 0, False, "fixture", {}, response)
|
|
243
|
+
with pytest.raises(OSError, match="write failed"):
|
|
244
|
+
result.write_to(BrokenWriter())
|
|
245
|
+
assert response.closed
|
|
246
|
+
response = io.BytesIO(b"abandoned")
|
|
247
|
+
with RenderResult("image/png", 0, 0, False, "fixture", {}, response):
|
|
248
|
+
pass
|
|
249
|
+
assert response.closed
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import sys
|
|
3
|
+
import warnings
|
|
4
|
+
|
|
5
|
+
import ironfang_render
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_old_module_warns_and_gives_the_same_objects():
|
|
9
|
+
for name in [m for m in sys.modules if m.startswith("ironfang_renderwolf")]:
|
|
10
|
+
del sys.modules[name]
|
|
11
|
+
with warnings.catch_warnings(record=True) as caught:
|
|
12
|
+
warnings.simplefilter("always")
|
|
13
|
+
old = importlib.import_module("ironfang_renderwolf")
|
|
14
|
+
assert any(w.category is DeprecationWarning for w in caught)
|
|
15
|
+
assert old.Renderwolf is ironfang_render.IronfangRender
|
|
16
|
+
assert old.RenderwolfError is ironfang_render.IronfangRenderError
|
|
17
|
+
assert importlib.import_module("ironfang_renderwolf.webhook") is ironfang_render.webhook
|