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.
- rendshot-0.1.0/.gitignore +10 -0
- rendshot-0.1.0/PKG-INFO +136 -0
- rendshot-0.1.0/README.md +108 -0
- rendshot-0.1.0/pyproject.toml +41 -0
- rendshot-0.1.0/rendshot/__init__.py +24 -0
- rendshot-0.1.0/rendshot/_version.py +1 -0
- rendshot-0.1.0/rendshot/client.py +166 -0
- rendshot-0.1.0/rendshot/errors.py +16 -0
- rendshot-0.1.0/rendshot/py.typed +0 -0
- rendshot-0.1.0/rendshot/types.py +137 -0
- rendshot-0.1.0/tests/__init__.py +0 -0
- rendshot-0.1.0/tests/test_client.py +454 -0
rendshot-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
rendshot-0.1.0/README.md
ADDED
|
@@ -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
|