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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ ironfang_render
2
+ ironfang_renderwolf
@@ -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