rendshot 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ node_modules/
2
+ dist/
3
+ .env
4
+ .env.*
5
+ !.env.example
6
+ *.tgz
7
+ .turbo/
8
+ .vercel
9
+ bun.lock
10
+ bun.lockb
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: rendshot
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for RendShot — HTML-to-image rendering and URL screenshots
5
+ Project-URL: Homepage, https://rendshot.ai
6
+ Project-URL: Documentation, https://rendshot.ai/docs/sdk
7
+ Project-URL: Repository, https://github.com/nicepkg/rendshot
8
+ Author-email: RendShot <support@rendshot.ai>
9
+ License-Expression: MIT
10
+ Keywords: api,html-to-image,rendering,rendshot,screenshot,sdk
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.9
22
+ Requires-Dist: httpx>=0.27
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
25
+ Requires-Dist: pytest>=8; extra == 'dev'
26
+ Requires-Dist: respx>=0.22; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # rendshot
30
+
31
+ Official Python SDK for [RendShot](https://rendshot.ai) — HTML-to-image rendering and URL screenshots.
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ pip install rendshot
37
+ ```
38
+
39
+ ## Quick Start
40
+
41
+ ```python
42
+ from rendshot import RendshotClient, RenderImageOptions, ScreenshotUrlOptions
43
+
44
+ client = RendshotClient(api_key="rs_live_...")
45
+
46
+ # Render HTML to image
47
+ result = client.render_image(RenderImageOptions(
48
+ html="<h1 style='color: green;'>Hello World</h1>",
49
+ width=800,
50
+ height=400,
51
+ ))
52
+ print(result.url)
53
+
54
+ # Screenshot a URL
55
+ result = client.screenshot_url(ScreenshotUrlOptions(url="https://example.com"))
56
+ print(result.url)
57
+ ```
58
+
59
+ ## Async
60
+
61
+ ```python
62
+ from rendshot import AsyncRendshotClient, ScreenshotUrlOptions
63
+
64
+ async with AsyncRendshotClient(api_key="rs_live_...") as client:
65
+ result = await client.screenshot_url(
66
+ ScreenshotUrlOptions(url="https://example.com", full_page=True)
67
+ )
68
+ print(result.url)
69
+ ```
70
+
71
+ ## API
72
+
73
+ ### `RendshotClient` / `AsyncRendshotClient`
74
+
75
+ ```python
76
+ client = RendshotClient(api_key="rs_live_...", base_url="https://api.rendshot.ai")
77
+ ```
78
+
79
+ Both clients support context managers and expose the same methods:
80
+
81
+ | Method | Description |
82
+ |--------|-------------|
83
+ | `render_image(options)` | Render HTML/CSS to an image |
84
+ | `screenshot_url(options)` | Take a screenshot of a URL |
85
+ | `get_usage()` | Get current month's usage |
86
+ | `get_image(image_id)` | Get metadata for a specific image |
87
+
88
+ ### Options
89
+
90
+ All option classes accept keyword arguments and convert Python `snake_case` to API `camelCase` automatically.
91
+
92
+ **`RenderImageOptions`**
93
+
94
+ | Parameter | Type | Default | Description |
95
+ |-----------|------|---------|-------------|
96
+ | `html` | `str` | required | HTML content to render |
97
+ | `css` | `str` | — | Optional CSS styles |
98
+ | `width` | `int` | 1080 | Image width (1–4096) |
99
+ | `height` | `int` | 1080 | Image height (1–4096) |
100
+ | `format` | `"png" \| "jpg"` | `"png"` | Output format |
101
+ | `quality` | `int` | 90 | JPEG quality (1–100) |
102
+ | `device_scale` | `1 \| 2 \| 3` | 1 | Device scale factor |
103
+ | `fonts` | `list[str]` | — | Custom fonts |
104
+ | `timeout` | `int` | 10000 | Timeout in ms (1000–30000) |
105
+
106
+ **`ScreenshotUrlOptions`**
107
+
108
+ | Parameter | Type | Default | Description |
109
+ |-----------|------|---------|-------------|
110
+ | `url` | `str` | required | URL to screenshot |
111
+ | `width` | `int` | 1280 | Viewport width (1–4096) |
112
+ | `height` | `int` | 800 | Viewport height (1–4096) |
113
+ | `format` | `"png" \| "jpg"` | `"png"` | Output format |
114
+ | `quality` | `int` | 90 | JPEG quality (1–100) |
115
+ | `full_page` | `bool` | `False` | Capture full page |
116
+ | `device_scale` | `1 \| 2 \| 3` | 1 | Device scale factor |
117
+ | `timeout` | `int` | 10000 | Timeout in ms (1000–30000) |
118
+
119
+ ### Error Handling
120
+
121
+ ```python
122
+ from rendshot import RendshotClient, RendshotError, RenderImageOptions
123
+
124
+ client = RendshotClient(api_key="rs_live_...")
125
+
126
+ try:
127
+ client.render_image(RenderImageOptions(html="<h1>Hello</h1>"))
128
+ except RendshotError as e:
129
+ print(e.code) # "RATE_LIMIT_EXCEEDED"
130
+ print(e.status) # 429
131
+ print(e) # "Rate limited"
132
+ ```
133
+
134
+ ## License
135
+
136
+ MIT
@@ -0,0 +1,108 @@
1
+ # rendshot
2
+
3
+ Official Python SDK for [RendShot](https://rendshot.ai) — HTML-to-image rendering and URL screenshots.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install rendshot
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```python
14
+ from rendshot import RendshotClient, RenderImageOptions, ScreenshotUrlOptions
15
+
16
+ client = RendshotClient(api_key="rs_live_...")
17
+
18
+ # Render HTML to image
19
+ result = client.render_image(RenderImageOptions(
20
+ html="<h1 style='color: green;'>Hello World</h1>",
21
+ width=800,
22
+ height=400,
23
+ ))
24
+ print(result.url)
25
+
26
+ # Screenshot a URL
27
+ result = client.screenshot_url(ScreenshotUrlOptions(url="https://example.com"))
28
+ print(result.url)
29
+ ```
30
+
31
+ ## Async
32
+
33
+ ```python
34
+ from rendshot import AsyncRendshotClient, ScreenshotUrlOptions
35
+
36
+ async with AsyncRendshotClient(api_key="rs_live_...") as client:
37
+ result = await client.screenshot_url(
38
+ ScreenshotUrlOptions(url="https://example.com", full_page=True)
39
+ )
40
+ print(result.url)
41
+ ```
42
+
43
+ ## API
44
+
45
+ ### `RendshotClient` / `AsyncRendshotClient`
46
+
47
+ ```python
48
+ client = RendshotClient(api_key="rs_live_...", base_url="https://api.rendshot.ai")
49
+ ```
50
+
51
+ Both clients support context managers and expose the same methods:
52
+
53
+ | Method | Description |
54
+ |--------|-------------|
55
+ | `render_image(options)` | Render HTML/CSS to an image |
56
+ | `screenshot_url(options)` | Take a screenshot of a URL |
57
+ | `get_usage()` | Get current month's usage |
58
+ | `get_image(image_id)` | Get metadata for a specific image |
59
+
60
+ ### Options
61
+
62
+ All option classes accept keyword arguments and convert Python `snake_case` to API `camelCase` automatically.
63
+
64
+ **`RenderImageOptions`**
65
+
66
+ | Parameter | Type | Default | Description |
67
+ |-----------|------|---------|-------------|
68
+ | `html` | `str` | required | HTML content to render |
69
+ | `css` | `str` | — | Optional CSS styles |
70
+ | `width` | `int` | 1080 | Image width (1–4096) |
71
+ | `height` | `int` | 1080 | Image height (1–4096) |
72
+ | `format` | `"png" \| "jpg"` | `"png"` | Output format |
73
+ | `quality` | `int` | 90 | JPEG quality (1–100) |
74
+ | `device_scale` | `1 \| 2 \| 3` | 1 | Device scale factor |
75
+ | `fonts` | `list[str]` | — | Custom fonts |
76
+ | `timeout` | `int` | 10000 | Timeout in ms (1000–30000) |
77
+
78
+ **`ScreenshotUrlOptions`**
79
+
80
+ | Parameter | Type | Default | Description |
81
+ |-----------|------|---------|-------------|
82
+ | `url` | `str` | required | URL to screenshot |
83
+ | `width` | `int` | 1280 | Viewport width (1–4096) |
84
+ | `height` | `int` | 800 | Viewport height (1–4096) |
85
+ | `format` | `"png" \| "jpg"` | `"png"` | Output format |
86
+ | `quality` | `int` | 90 | JPEG quality (1–100) |
87
+ | `full_page` | `bool` | `False` | Capture full page |
88
+ | `device_scale` | `1 \| 2 \| 3` | 1 | Device scale factor |
89
+ | `timeout` | `int` | 10000 | Timeout in ms (1000–30000) |
90
+
91
+ ### Error Handling
92
+
93
+ ```python
94
+ from rendshot import RendshotClient, RendshotError, RenderImageOptions
95
+
96
+ client = RendshotClient(api_key="rs_live_...")
97
+
98
+ try:
99
+ client.render_image(RenderImageOptions(html="<h1>Hello</h1>"))
100
+ except RendshotError as e:
101
+ print(e.code) # "RATE_LIMIT_EXCEEDED"
102
+ print(e.status) # 429
103
+ print(e) # "Rate limited"
104
+ ```
105
+
106
+ ## License
107
+
108
+ MIT
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "rendshot"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for RendShot — HTML-to-image rendering and URL screenshots"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.9"
12
+ authors = [{ name = "RendShot", email = "support@rendshot.ai" }]
13
+ keywords = ["rendshot", "screenshot", "html-to-image", "rendering", "sdk", "api"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Typing :: Typed",
25
+ ]
26
+ dependencies = ["httpx>=0.27"]
27
+
28
+ [project.urls]
29
+ Homepage = "https://rendshot.ai"
30
+ Documentation = "https://rendshot.ai/docs/sdk"
31
+ Repository = "https://github.com/nicepkg/rendshot"
32
+
33
+ [project.optional-dependencies]
34
+ dev = ["pytest>=8", "pytest-asyncio>=0.24", "respx>=0.22"]
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["rendshot"]
38
+
39
+ [tool.pytest.ini_options]
40
+ asyncio_mode = "auto"
41
+ asyncio_default_fixture_loop_scope = "function"
@@ -0,0 +1,24 @@
1
+ """Official Python SDK for RendShot — HTML-to-image rendering and URL screenshots."""
2
+
3
+ from rendshot.client import RendshotClient, AsyncRendshotClient
4
+ from rendshot.errors import RendshotError
5
+ from rendshot.types import (
6
+ RenderImageOptions,
7
+ ScreenshotUrlOptions,
8
+ ImageResult,
9
+ ImageMetadata,
10
+ UsageResult,
11
+ )
12
+ from rendshot._version import __version__
13
+
14
+ __all__ = [
15
+ "RendshotClient",
16
+ "AsyncRendshotClient",
17
+ "RendshotError",
18
+ "RenderImageOptions",
19
+ "ScreenshotUrlOptions",
20
+ "ImageResult",
21
+ "ImageMetadata",
22
+ "UsageResult",
23
+ "__version__",
24
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,166 @@
1
+ """Sync and async HTTP clients for the RendShot API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+ from urllib.parse import quote
7
+
8
+ import httpx
9
+
10
+ from rendshot._version import __version__
11
+ from rendshot.errors import RendshotError
12
+ from rendshot.types import (
13
+ ImageMetadata,
14
+ ImageResult,
15
+ RenderImageOptions,
16
+ ScreenshotUrlOptions,
17
+ UsageResult,
18
+ )
19
+
20
+ _DEFAULT_BASE_URL = "https://api.rendshot.ai"
21
+ _TIMEOUT = httpx.Timeout(60.0, connect=10.0)
22
+
23
+
24
+ def _base_headers(api_key: str) -> dict[str, str]:
25
+ return {
26
+ "Authorization": f"Bearer {api_key}",
27
+ "User-Agent": f"rendshot-python/{__version__}",
28
+ }
29
+
30
+
31
+ def _handle_response(response: httpx.Response) -> Any:
32
+ try:
33
+ data = response.json()
34
+ except (ValueError, httpx.DecodingError):
35
+ if not response.is_success:
36
+ raise RendshotError("UNKNOWN_ERROR", f"API error: {response.status_code}", response.status_code)
37
+ raise ValueError(f"Unexpected non-JSON response (status {response.status_code})")
38
+
39
+ if not response.is_success:
40
+ err = data.get("error", {}) if isinstance(data, dict) else {}
41
+ raise RendshotError(
42
+ code=err.get("code", "UNKNOWN_ERROR"),
43
+ message=err.get("message", f"API error: {response.status_code}"),
44
+ status=err.get("status", response.status_code),
45
+ )
46
+ return data
47
+
48
+
49
+ class RendshotClient:
50
+ """Synchronous RendShot API client.
51
+
52
+ Usage::
53
+
54
+ from rendshot import RendshotClient, RenderImageOptions
55
+
56
+ client = RendshotClient(api_key="rs_live_...")
57
+ result = client.render_image(RenderImageOptions(html="<h1>Hello</h1>"))
58
+ print(result.url)
59
+ """
60
+
61
+ def __init__(self, api_key: str, *, base_url: str = _DEFAULT_BASE_URL) -> None:
62
+ self._base_url = base_url.rstrip("/")
63
+ self._client = httpx.Client(
64
+ headers=_base_headers(api_key),
65
+ timeout=_TIMEOUT,
66
+ )
67
+
68
+ def render_image(self, options: RenderImageOptions | dict) -> ImageResult:
69
+ """Render HTML/CSS to an image."""
70
+ body = options.to_dict() if isinstance(options, RenderImageOptions) else options
71
+ data = self._post("/v1/image", body)
72
+ return ImageResult.from_dict(data)
73
+
74
+ def screenshot_url(self, options: ScreenshotUrlOptions | dict) -> ImageResult:
75
+ """Take a screenshot of a URL."""
76
+ body = options.to_dict() if isinstance(options, ScreenshotUrlOptions) else options
77
+ data = self._post("/v1/screenshot", body)
78
+ return ImageResult.from_dict(data)
79
+
80
+ def get_usage(self) -> UsageResult:
81
+ """Get current month's usage."""
82
+ data = self._get("/v1/usage")
83
+ return UsageResult.from_dict(data)
84
+
85
+ def get_image(self, image_id: str) -> ImageMetadata:
86
+ """Get metadata for a specific image."""
87
+ data = self._get(f"/v1/image/{quote(image_id, safe='')}")
88
+ return ImageMetadata.from_dict(data)
89
+
90
+ def close(self) -> None:
91
+ """Close the underlying HTTP client."""
92
+ self._client.close()
93
+
94
+ def __enter__(self) -> "RendshotClient":
95
+ return self
96
+
97
+ def __exit__(self, *args: Any) -> None:
98
+ self.close()
99
+
100
+ def _post(self, path: str, body: dict) -> Any:
101
+ resp = self._client.post(f"{self._base_url}{path}", json=body)
102
+ return _handle_response(resp)
103
+
104
+ def _get(self, path: str) -> Any:
105
+ resp = self._client.get(f"{self._base_url}{path}")
106
+ return _handle_response(resp)
107
+
108
+
109
+ class AsyncRendshotClient:
110
+ """Async RendShot API client.
111
+
112
+ Usage::
113
+
114
+ from rendshot import AsyncRendshotClient, RenderImageOptions
115
+
116
+ async with AsyncRendshotClient(api_key="rs_live_...") as client:
117
+ result = await client.render_image(RenderImageOptions(html="<h1>Hello</h1>"))
118
+ print(result.url)
119
+ """
120
+
121
+ def __init__(self, api_key: str, *, base_url: str = _DEFAULT_BASE_URL) -> None:
122
+ self._base_url = base_url.rstrip("/")
123
+ self._client = httpx.AsyncClient(
124
+ headers=_base_headers(api_key),
125
+ timeout=_TIMEOUT,
126
+ )
127
+
128
+ async def render_image(self, options: RenderImageOptions | dict) -> ImageResult:
129
+ """Render HTML/CSS to an image."""
130
+ body = options.to_dict() if isinstance(options, RenderImageOptions) else options
131
+ data = await self._post("/v1/image", body)
132
+ return ImageResult.from_dict(data)
133
+
134
+ async def screenshot_url(self, options: ScreenshotUrlOptions | dict) -> ImageResult:
135
+ """Take a screenshot of a URL."""
136
+ body = options.to_dict() if isinstance(options, ScreenshotUrlOptions) else options
137
+ data = await self._post("/v1/screenshot", body)
138
+ return ImageResult.from_dict(data)
139
+
140
+ async def get_usage(self) -> UsageResult:
141
+ """Get current month's usage."""
142
+ data = await self._get("/v1/usage")
143
+ return UsageResult.from_dict(data)
144
+
145
+ async def get_image(self, image_id: str) -> ImageMetadata:
146
+ """Get metadata for a specific image."""
147
+ data = await self._get(f"/v1/image/{quote(image_id, safe='')}")
148
+ return ImageMetadata.from_dict(data)
149
+
150
+ async def close(self) -> None:
151
+ """Close the underlying HTTP client."""
152
+ await self._client.aclose()
153
+
154
+ async def __aenter__(self) -> "AsyncRendshotClient":
155
+ return self
156
+
157
+ async def __aexit__(self, *args: Any) -> None:
158
+ await self.close()
159
+
160
+ async def _post(self, path: str, body: dict) -> Any:
161
+ resp = await self._client.post(f"{self._base_url}{path}", json=body)
162
+ return _handle_response(resp)
163
+
164
+ async def _get(self, path: str) -> Any:
165
+ resp = await self._client.get(f"{self._base_url}{path}")
166
+ return _handle_response(resp)
@@ -0,0 +1,16 @@
1
+ """Error types for the RendShot SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class RendshotError(Exception):
7
+ """Error returned by the RendShot API."""
8
+
9
+ def __init__(self, code: str, message: str, status: int) -> None:
10
+ super().__init__(message)
11
+ self.code = code
12
+ self.message = message
13
+ self.status = status
14
+
15
+ def __repr__(self) -> str:
16
+ return f"RendshotError(code={self.code!r}, status={self.status}, message={self.message!r})"
File without changes
@@ -0,0 +1,137 @@
1
+ """Type definitions for the RendShot SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Literal
7
+
8
+
9
+ @dataclass
10
+ class RenderImageOptions:
11
+ """Options for rendering HTML to an image."""
12
+
13
+ html: str
14
+ css: str | None = None
15
+ width: int | None = None
16
+ height: int | None = None
17
+ format: Literal["png", "jpg"] | None = None
18
+ quality: int | None = None
19
+ device_scale: Literal[1, 2, 3] | None = None
20
+ fonts: list[str] | None = None
21
+ timeout: int | None = None
22
+
23
+ def to_dict(self) -> dict:
24
+ d: dict = {"html": self.html}
25
+ if self.css is not None:
26
+ d["css"] = self.css
27
+ if self.width is not None:
28
+ d["width"] = self.width
29
+ if self.height is not None:
30
+ d["height"] = self.height
31
+ if self.format is not None:
32
+ d["format"] = self.format
33
+ if self.quality is not None:
34
+ d["quality"] = self.quality
35
+ if self.device_scale is not None:
36
+ d["deviceScale"] = self.device_scale
37
+ if self.fonts is not None:
38
+ d["fonts"] = self.fonts
39
+ if self.timeout is not None:
40
+ d["timeout"] = self.timeout
41
+ return d
42
+
43
+
44
+ @dataclass
45
+ class ScreenshotUrlOptions:
46
+ """Options for taking a URL screenshot."""
47
+
48
+ url: str
49
+ width: int | None = None
50
+ height: int | None = None
51
+ format: Literal["png", "jpg"] | None = None
52
+ quality: int | None = None
53
+ full_page: bool | None = None
54
+ device_scale: Literal[1, 2, 3] | None = None
55
+ timeout: int | None = None
56
+
57
+ def to_dict(self) -> dict:
58
+ d: dict = {"url": self.url}
59
+ if self.width is not None:
60
+ d["width"] = self.width
61
+ if self.height is not None:
62
+ d["height"] = self.height
63
+ if self.format is not None:
64
+ d["format"] = self.format
65
+ if self.quality is not None:
66
+ d["quality"] = self.quality
67
+ if self.full_page is not None:
68
+ d["fullPage"] = self.full_page
69
+ if self.device_scale is not None:
70
+ d["deviceScale"] = self.device_scale
71
+ if self.timeout is not None:
72
+ d["timeout"] = self.timeout
73
+ return d
74
+
75
+
76
+ @dataclass
77
+ class ImageResult:
78
+ """Result from a render or screenshot operation."""
79
+
80
+ image_id: str
81
+ url: str
82
+ width: int
83
+ height: int
84
+ format: str
85
+ size: int
86
+ created_at: str
87
+
88
+ @classmethod
89
+ def from_dict(cls, data: dict) -> "ImageResult":
90
+ return cls(
91
+ image_id=data["imageId"],
92
+ url=data["url"],
93
+ width=data["width"],
94
+ height=data["height"],
95
+ format=data["format"],
96
+ size=data["size"],
97
+ created_at=data["createdAt"],
98
+ )
99
+
100
+
101
+ @dataclass
102
+ class ImageMetadata(ImageResult):
103
+ """Image metadata including expiration."""
104
+
105
+ expires_at: str | None = None
106
+
107
+ @classmethod
108
+ def from_dict(cls, data: dict) -> "ImageMetadata":
109
+ return cls(
110
+ image_id=data["imageId"],
111
+ url=data["url"],
112
+ width=data["width"],
113
+ height=data["height"],
114
+ format=data["format"],
115
+ size=data["size"],
116
+ created_at=data["createdAt"],
117
+ expires_at=data.get("expiresAt"),
118
+ )
119
+
120
+
121
+ @dataclass
122
+ class UsageResult:
123
+ """Current usage information."""
124
+
125
+ month: str
126
+ count: int
127
+ limit: int
128
+ plan: str
129
+
130
+ @classmethod
131
+ def from_dict(cls, data: dict) -> "UsageResult":
132
+ return cls(
133
+ month=data["month"],
134
+ count=data["count"],
135
+ limit=data["limit"],
136
+ plan=data["plan"],
137
+ )
File without changes
@@ -0,0 +1,454 @@
1
+ """Tests for the RendShot Python SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+ import httpx
7
+ import respx
8
+
9
+ from rendshot import (
10
+ RendshotClient,
11
+ AsyncRendshotClient,
12
+ RendshotError,
13
+ RenderImageOptions,
14
+ ScreenshotUrlOptions,
15
+ ImageResult,
16
+ ImageMetadata,
17
+ UsageResult,
18
+ )
19
+
20
+ BASE = "https://api.rendshot.ai"
21
+
22
+ IMAGE_RESP = {
23
+ "imageId": "img_1",
24
+ "url": "https://cdn/img.png",
25
+ "width": 800,
26
+ "height": 600,
27
+ "format": "png",
28
+ "size": 1234,
29
+ "createdAt": "2026-03-20",
30
+ }
31
+
32
+ USAGE_RESP = {"month": "2026-03", "count": 42, "limit": 1000, "plan": "pro"}
33
+
34
+ META_RESP = {**IMAGE_RESP, "imageId": "img_3", "expiresAt": "2026-03-27"}
35
+
36
+
37
+ # ---------------------------------------------------------------------------
38
+ # Constructor
39
+ # ---------------------------------------------------------------------------
40
+
41
+
42
+ class TestConstructor:
43
+ @respx.mock
44
+ def test_default_base_url(self):
45
+ route = respx.get(f"{BASE}/v1/usage").mock(
46
+ return_value=httpx.Response(200, json=USAGE_RESP)
47
+ )
48
+ client = RendshotClient(api_key="rs_live_test")
49
+ client.get_usage()
50
+ assert route.called
51
+
52
+ @respx.mock
53
+ def test_strips_trailing_slash(self):
54
+ route = respx.get("https://custom.api.com/v1/usage").mock(
55
+ return_value=httpx.Response(200, json=USAGE_RESP)
56
+ )
57
+ client = RendshotClient(api_key="rs_live_test", base_url="https://custom.api.com/")
58
+ client.get_usage()
59
+ assert route.called
60
+
61
+ @respx.mock
62
+ def test_context_manager(self):
63
+ respx.get(f"{BASE}/v1/usage").mock(
64
+ return_value=httpx.Response(200, json=USAGE_RESP)
65
+ )
66
+ with RendshotClient(api_key="rs_live_test") as client:
67
+ result = client.get_usage()
68
+ assert result.plan == "pro"
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Headers
73
+ # ---------------------------------------------------------------------------
74
+
75
+
76
+ class TestHeaders:
77
+ @respx.mock
78
+ def test_sends_auth_and_user_agent_on_post(self):
79
+ route = respx.post(f"{BASE}/v1/image").mock(
80
+ return_value=httpx.Response(200, json=IMAGE_RESP)
81
+ )
82
+ client = RendshotClient(api_key="rs_live_abc")
83
+ client.render_image(RenderImageOptions(html="<h1>hi</h1>"))
84
+
85
+ req = route.calls[0].request
86
+ assert req.headers["authorization"] == "Bearer rs_live_abc"
87
+ assert "rendshot-python/" in req.headers["user-agent"]
88
+
89
+ @respx.mock
90
+ def test_sends_auth_and_user_agent_on_get(self):
91
+ route = respx.get(f"{BASE}/v1/usage").mock(
92
+ return_value=httpx.Response(200, json=USAGE_RESP)
93
+ )
94
+ client = RendshotClient(api_key="rs_live_abc")
95
+ client.get_usage()
96
+
97
+ req = route.calls[0].request
98
+ assert req.headers["authorization"] == "Bearer rs_live_abc"
99
+ assert "rendshot-python/" in req.headers["user-agent"]
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
+ # render_image
104
+ # ---------------------------------------------------------------------------
105
+
106
+
107
+ class TestRenderImage:
108
+ @respx.mock
109
+ def test_posts_to_v1_image(self):
110
+ route = respx.post(f"{BASE}/v1/image").mock(
111
+ return_value=httpx.Response(200, json=IMAGE_RESP)
112
+ )
113
+ client = RendshotClient(api_key="rs_live_test")
114
+ result = client.render_image(
115
+ RenderImageOptions(html="<h1>Hello</h1>", width=800, height=600, format="png")
116
+ )
117
+
118
+ assert route.called
119
+ import json
120
+ body = json.loads(route.calls[0].request.content)
121
+ assert body["html"] == "<h1>Hello</h1>"
122
+ assert body["width"] == 800
123
+ assert isinstance(result, ImageResult)
124
+ assert result.image_id == "img_1"
125
+ assert result.url == "https://cdn/img.png"
126
+
127
+ @respx.mock
128
+ def test_accepts_dict(self):
129
+ respx.post(f"{BASE}/v1/image").mock(
130
+ return_value=httpx.Response(200, json=IMAGE_RESP)
131
+ )
132
+ client = RendshotClient(api_key="rs_live_test")
133
+ result = client.render_image({"html": "<h1>Hello</h1>"})
134
+ assert result.image_id == "img_1"
135
+
136
+ @respx.mock
137
+ def test_fonts_and_timeout_reach_wire(self):
138
+ route = respx.post(f"{BASE}/v1/image").mock(
139
+ return_value=httpx.Response(200, json=IMAGE_RESP)
140
+ )
141
+ client = RendshotClient(api_key="rs_live_test")
142
+ client.render_image(RenderImageOptions(html="<h1>x</h1>", fonts=["Inter", "Roboto"], timeout=5000))
143
+
144
+ import json
145
+ body = json.loads(route.calls[0].request.content)
146
+ assert body["fonts"] == ["Inter", "Roboto"]
147
+ assert body["timeout"] == 5000
148
+
149
+ @respx.mock
150
+ def test_device_scale_maps_to_camel_case(self):
151
+ route = respx.post(f"{BASE}/v1/image").mock(
152
+ return_value=httpx.Response(200, json=IMAGE_RESP)
153
+ )
154
+ client = RendshotClient(api_key="rs_live_test")
155
+ client.render_image(RenderImageOptions(html="<h1>x</h1>", device_scale=2))
156
+
157
+ import json
158
+ body = json.loads(route.calls[0].request.content)
159
+ assert body["deviceScale"] == 2
160
+ assert "device_scale" not in body
161
+
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # screenshot_url
165
+ # ---------------------------------------------------------------------------
166
+
167
+
168
+ class TestScreenshotUrl:
169
+ @respx.mock
170
+ def test_posts_to_v1_screenshot(self):
171
+ shot_resp = {**IMAGE_RESP, "imageId": "img_2", "width": 1280, "height": 800}
172
+ route = respx.post(f"{BASE}/v1/screenshot").mock(
173
+ return_value=httpx.Response(200, json=shot_resp)
174
+ )
175
+ client = RendshotClient(api_key="rs_live_test")
176
+ result = client.screenshot_url(
177
+ ScreenshotUrlOptions(url="https://example.com", full_page=True)
178
+ )
179
+
180
+ import json
181
+ body = json.loads(route.calls[0].request.content)
182
+ assert body["url"] == "https://example.com"
183
+ assert body["fullPage"] is True
184
+ assert result.image_id == "img_2"
185
+
186
+ @respx.mock
187
+ def test_accepts_dict(self):
188
+ respx.post(f"{BASE}/v1/screenshot").mock(
189
+ return_value=httpx.Response(200, json=IMAGE_RESP)
190
+ )
191
+ client = RendshotClient(api_key="rs_live_test")
192
+ result = client.screenshot_url({"url": "https://example.com"})
193
+ assert result.image_id == "img_1"
194
+
195
+ @respx.mock
196
+ def test_device_scale_maps_to_camel_case(self):
197
+ route = respx.post(f"{BASE}/v1/screenshot").mock(
198
+ return_value=httpx.Response(200, json=IMAGE_RESP)
199
+ )
200
+ client = RendshotClient(api_key="rs_live_test")
201
+ client.screenshot_url(ScreenshotUrlOptions(url="https://example.com", device_scale=2))
202
+
203
+ import json
204
+ body = json.loads(route.calls[0].request.content)
205
+ assert body["deviceScale"] == 2
206
+ assert "device_scale" not in body
207
+
208
+
209
+ # ---------------------------------------------------------------------------
210
+ # get_usage
211
+ # ---------------------------------------------------------------------------
212
+
213
+
214
+ class TestGetUsage:
215
+ @respx.mock
216
+ def test_returns_typed_result(self):
217
+ respx.get(f"{BASE}/v1/usage").mock(
218
+ return_value=httpx.Response(200, json=USAGE_RESP)
219
+ )
220
+ client = RendshotClient(api_key="rs_live_test")
221
+ result = client.get_usage()
222
+
223
+ assert isinstance(result, UsageResult)
224
+ assert result.plan == "pro"
225
+ assert result.count == 42
226
+ assert result.limit == 1000
227
+
228
+
229
+ # ---------------------------------------------------------------------------
230
+ # get_image
231
+ # ---------------------------------------------------------------------------
232
+
233
+
234
+ class TestGetImage:
235
+ @respx.mock
236
+ def test_returns_metadata(self):
237
+ respx.get(f"{BASE}/v1/image/img_3").mock(
238
+ return_value=httpx.Response(200, json=META_RESP)
239
+ )
240
+ client = RendshotClient(api_key="rs_live_test")
241
+ result = client.get_image("img_3")
242
+
243
+ assert isinstance(result, ImageMetadata)
244
+ assert result.expires_at == "2026-03-27"
245
+
246
+ @respx.mock
247
+ def test_encodes_image_id(self):
248
+ encoded_resp = {**META_RESP, "imageId": "img/special"}
249
+ route = respx.get(f"{BASE}/v1/image/img%2Fspecial").mock(
250
+ return_value=httpx.Response(200, json=encoded_resp)
251
+ )
252
+ client = RendshotClient(api_key="rs_live_test")
253
+ client.get_image("img/special")
254
+ assert route.called
255
+
256
+
257
+ # ---------------------------------------------------------------------------
258
+ # Error handling
259
+ # ---------------------------------------------------------------------------
260
+
261
+
262
+ class TestErrors:
263
+ @respx.mock
264
+ def test_api_error_with_code_and_status(self):
265
+ respx.post(f"{BASE}/v1/image").mock(
266
+ return_value=httpx.Response(
267
+ 429,
268
+ json={"error": {"code": "RATE_LIMIT_EXCEEDED", "message": "Rate limited", "status": 429}},
269
+ )
270
+ )
271
+ client = RendshotClient(api_key="rs_live_test")
272
+ with pytest.raises(RendshotError) as exc_info:
273
+ client.render_image(RenderImageOptions(html="<p>x</p>"))
274
+
275
+ assert exc_info.value.code == "RATE_LIMIT_EXCEEDED"
276
+ assert exc_info.value.status == 429
277
+ assert exc_info.value.message == "Rate limited"
278
+ assert "Rate limited" in str(exc_info.value)
279
+
280
+ @respx.mock
281
+ def test_fallback_when_no_error_field(self):
282
+ respx.post(f"{BASE}/v1/image").mock(
283
+ return_value=httpx.Response(500, json={})
284
+ )
285
+ client = RendshotClient(api_key="rs_live_test")
286
+ with pytest.raises(RendshotError) as exc_info:
287
+ client.render_image(RenderImageOptions(html="<p>x</p>"))
288
+
289
+ assert exc_info.value.code == "UNKNOWN_ERROR"
290
+ assert exc_info.value.status == 500
291
+
292
+ @respx.mock
293
+ def test_non_json_error_response(self):
294
+ respx.post(f"{BASE}/v1/image").mock(
295
+ return_value=httpx.Response(502, text="Bad Gateway")
296
+ )
297
+ client = RendshotClient(api_key="rs_live_test")
298
+ with pytest.raises(RendshotError) as exc_info:
299
+ client.render_image(RenderImageOptions(html="<p>x</p>"))
300
+
301
+ assert exc_info.value.code == "UNKNOWN_ERROR"
302
+ assert exc_info.value.status == 502
303
+
304
+ @respx.mock
305
+ def test_non_dict_json_error_body(self):
306
+ respx.post(f"{BASE}/v1/image").mock(
307
+ return_value=httpx.Response(500, json=None)
308
+ )
309
+ client = RendshotClient(api_key="rs_live_test")
310
+ with pytest.raises(RendshotError) as exc_info:
311
+ client.render_image(RenderImageOptions(html="<p>x</p>"))
312
+ assert exc_info.value.code == "UNKNOWN_ERROR"
313
+ assert exc_info.value.status == 500
314
+
315
+ @respx.mock
316
+ def test_non_json_ok_response(self):
317
+ respx.post(f"{BASE}/v1/image").mock(
318
+ return_value=httpx.Response(200, text="OK")
319
+ )
320
+ client = RendshotClient(api_key="rs_live_test")
321
+ with pytest.raises(ValueError, match="non-JSON"):
322
+ client.render_image(RenderImageOptions(html="<p>x</p>"))
323
+
324
+
325
+ # ---------------------------------------------------------------------------
326
+ # Async client
327
+ # ---------------------------------------------------------------------------
328
+
329
+
330
+ class TestAsyncClient:
331
+ @respx.mock
332
+ async def test_render_image(self):
333
+ respx.post(f"{BASE}/v1/image").mock(
334
+ return_value=httpx.Response(200, json=IMAGE_RESP)
335
+ )
336
+ async with AsyncRendshotClient(api_key="rs_live_test") as client:
337
+ result = await client.render_image(RenderImageOptions(html="<h1>hi</h1>"))
338
+ assert result.image_id == "img_1"
339
+
340
+ @respx.mock
341
+ async def test_screenshot_url(self):
342
+ respx.post(f"{BASE}/v1/screenshot").mock(
343
+ return_value=httpx.Response(200, json=IMAGE_RESP)
344
+ )
345
+ async with AsyncRendshotClient(api_key="rs_live_test") as client:
346
+ result = await client.screenshot_url(ScreenshotUrlOptions(url="https://example.com"))
347
+ assert result.image_id == "img_1"
348
+
349
+ @respx.mock
350
+ async def test_get_usage(self):
351
+ respx.get(f"{BASE}/v1/usage").mock(
352
+ return_value=httpx.Response(200, json=USAGE_RESP)
353
+ )
354
+ async with AsyncRendshotClient(api_key="rs_live_test") as client:
355
+ result = await client.get_usage()
356
+ assert result.plan == "pro"
357
+
358
+ @respx.mock
359
+ async def test_get_image(self):
360
+ respx.get(f"{BASE}/v1/image/img_3").mock(
361
+ return_value=httpx.Response(200, json=META_RESP)
362
+ )
363
+ async with AsyncRendshotClient(api_key="rs_live_test") as client:
364
+ result = await client.get_image("img_3")
365
+ assert result.expires_at == "2026-03-27"
366
+
367
+ @respx.mock
368
+ async def test_error_handling(self):
369
+ respx.post(f"{BASE}/v1/image").mock(
370
+ return_value=httpx.Response(
371
+ 401,
372
+ json={"error": {"code": "UNAUTHORIZED", "message": "Invalid API key", "status": 401}},
373
+ )
374
+ )
375
+ async with AsyncRendshotClient(api_key="bad_key") as client:
376
+ with pytest.raises(RendshotError) as exc_info:
377
+ await client.render_image(RenderImageOptions(html="<p>x</p>"))
378
+ assert exc_info.value.code == "UNAUTHORIZED"
379
+
380
+ @respx.mock
381
+ async def test_non_json_error_response(self):
382
+ respx.post(f"{BASE}/v1/image").mock(
383
+ return_value=httpx.Response(502, text="Bad Gateway")
384
+ )
385
+ async with AsyncRendshotClient(api_key="rs_live_test") as client:
386
+ with pytest.raises(RendshotError) as exc_info:
387
+ await client.render_image(RenderImageOptions(html="<p>x</p>"))
388
+ assert exc_info.value.code == "UNKNOWN_ERROR"
389
+ assert exc_info.value.status == 502
390
+
391
+ @respx.mock
392
+ async def test_custom_base_url(self):
393
+ route = respx.get("https://custom.api.com/v1/usage").mock(
394
+ return_value=httpx.Response(200, json=USAGE_RESP)
395
+ )
396
+ async with AsyncRendshotClient(api_key="rs_live_test", base_url="https://custom.api.com/") as client:
397
+ await client.get_usage()
398
+ assert route.called
399
+
400
+
401
+ # ---------------------------------------------------------------------------
402
+ # Types
403
+ # ---------------------------------------------------------------------------
404
+
405
+
406
+ class TestTypes:
407
+ def test_render_options_to_dict_minimal(self):
408
+ opts = RenderImageOptions(html="<h1>hi</h1>")
409
+ d = opts.to_dict()
410
+ assert d == {"html": "<h1>hi</h1>"}
411
+
412
+ def test_render_options_to_dict_full(self):
413
+ opts = RenderImageOptions(
414
+ html="<h1>hi</h1>",
415
+ css="h1 { color: red; }",
416
+ width=800,
417
+ height=600,
418
+ format="jpg",
419
+ quality=85,
420
+ device_scale=2,
421
+ fonts=["Inter"],
422
+ timeout=5000,
423
+ )
424
+ d = opts.to_dict()
425
+ assert d["css"] == "h1 { color: red; }"
426
+ assert d["deviceScale"] == 2
427
+ assert d["fonts"] == ["Inter"]
428
+ assert "device_scale" not in d
429
+
430
+ def test_screenshot_options_to_dict(self):
431
+ opts = ScreenshotUrlOptions(
432
+ url="https://example.com",
433
+ full_page=True,
434
+ device_scale=3,
435
+ )
436
+ d = opts.to_dict()
437
+ assert d["fullPage"] is True
438
+ assert d["deviceScale"] == 3
439
+ assert "full_page" not in d
440
+
441
+ def test_image_result_from_dict(self):
442
+ result = ImageResult.from_dict(IMAGE_RESP)
443
+ assert result.image_id == "img_1"
444
+ assert result.created_at == "2026-03-20"
445
+
446
+ def test_image_metadata_from_dict(self):
447
+ meta = ImageMetadata.from_dict(META_RESP)
448
+ assert meta.expires_at == "2026-03-27"
449
+ assert meta.image_id == "img_3"
450
+
451
+ def test_usage_result_from_dict(self):
452
+ usage = UsageResult.from_dict(USAGE_RESP)
453
+ assert usage.month == "2026-03"
454
+ assert usage.count == 42