rendex 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.
- rendex-0.1.0/.gitignore +1 -0
- rendex-0.1.0/CLAUDE.md +40 -0
- rendex-0.1.0/LICENSE +21 -0
- rendex-0.1.0/PKG-INFO +171 -0
- rendex-0.1.0/README.md +145 -0
- rendex-0.1.0/package.json +6 -0
- rendex-0.1.0/pyproject.toml +35 -0
- rendex-0.1.0/src/rendex/__init__.py +29 -0
- rendex-0.1.0/src/rendex/client.py +187 -0
- rendex-0.1.0/src/rendex/errors.py +47 -0
- rendex-0.1.0/src/rendex/py.typed +0 -0
- rendex-0.1.0/src/rendex/types.py +102 -0
- rendex-0.1.0/tests/test_client.py +239 -0
rendex-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__pycache__/
|
rendex-0.1.0/CLAUDE.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Rendex Python SDK
|
|
2
|
+
|
|
3
|
+
**Package**: `rendex` (PyPI)
|
|
4
|
+
**Parent**: Copperline Labs LLC
|
|
5
|
+
**Product**: Rendex (rendex.dev)
|
|
6
|
+
|
|
7
|
+
## Overview
|
|
8
|
+
|
|
9
|
+
Official Python SDK for the Rendex screenshot API. Thin wrapper (~80 lines of core logic) around the REST API at api.rendex.dev. Single dependency: `httpx`.
|
|
10
|
+
|
|
11
|
+
## Architecture
|
|
12
|
+
|
|
13
|
+
- `src/rendex/client.py` — `Rendex` class with 3 methods: `screenshot`, `screenshot_json`, `screenshot_url`
|
|
14
|
+
- `src/rendex/types.py` — TypedDict/dataclass definitions + camelCase conversion helpers
|
|
15
|
+
- `src/rendex/errors.py` — Exception hierarchy: `RendexError` → `RendexApiError` / `RendexNetworkError`
|
|
16
|
+
- `src/rendex/__init__.py` — Public exports
|
|
17
|
+
- `tests/test_client.py` — Unit tests with httpx MockTransport
|
|
18
|
+
|
|
19
|
+
## Key Design Decisions
|
|
20
|
+
|
|
21
|
+
- **snake_case API**: Python-idiomatic (`full_page`, `dark_mode`), auto-converts to camelCase for the API.
|
|
22
|
+
- **httpx over requests**: Supports sync + async, HTTP/2, modern timeout handling.
|
|
23
|
+
- **Context manager**: `with Rendex("key") as r:` for connection reuse.
|
|
24
|
+
- **Positional url arg**: `rendex.screenshot("https://example.com")` reads naturally.
|
|
25
|
+
|
|
26
|
+
## Development
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install -e ".[dev]" # Install in development mode
|
|
30
|
+
python -m pytest tests/ # Run tests
|
|
31
|
+
python -m mypy src/rendex/ # Type-check
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Publishing
|
|
35
|
+
|
|
36
|
+
Push a `sdk-py-v*` tag to trigger `.github/workflows/publish-sdk-python.yml`.
|
|
37
|
+
|
|
38
|
+
## Type Sync
|
|
39
|
+
|
|
40
|
+
Options must match `ScreenshotRequestSchema` in `packages/screenshot-api/src/services/screenshot.ts`. If the API adds new parameters, update `src/rendex/types.py` (snake_case version).
|
rendex-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Copperline Labs LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
rendex-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rendex
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Rendex screenshot API — capture any webpage as an image
|
|
5
|
+
Project-URL: Homepage, https://rendex.dev
|
|
6
|
+
Project-URL: Documentation, https://rendex.dev/docs
|
|
7
|
+
Project-URL: Repository, https://github.com/copperline-labs/copperline-labs
|
|
8
|
+
Project-URL: Issues, https://github.com/copperline-labs/copperline-labs/issues
|
|
9
|
+
Author-email: Copperline Labs LLC <support@rendex.dev>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: ai-agent,rendex,screenshot,screenshot-api,sdk,webpage-capture
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Requires-Dist: httpx>=0.27
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# rendex
|
|
28
|
+
|
|
29
|
+
Official Python SDK for the [Rendex](https://rendex.dev) screenshot API. Capture any webpage as a high-quality image with a single function call.
|
|
30
|
+
|
|
31
|
+
- Full type hints (PEP 561 compatible)
|
|
32
|
+
- Single dependency (`httpx`)
|
|
33
|
+
- Sync API with context manager support
|
|
34
|
+
- Typed error handling with API error codes
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install rendex
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quick Start
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from rendex import Rendex
|
|
47
|
+
|
|
48
|
+
rendex = Rendex("your-api-key")
|
|
49
|
+
|
|
50
|
+
# Capture a screenshot (returns binary image)
|
|
51
|
+
result = rendex.screenshot("https://example.com", format="png", full_page=True)
|
|
52
|
+
Path("screenshot.png").write_bytes(result.image)
|
|
53
|
+
|
|
54
|
+
print(f"{result.metadata.bytes_size} bytes, loaded in {result.metadata.load_time_ms}ms")
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## API Reference
|
|
58
|
+
|
|
59
|
+
### `Rendex(api_key, *, base_url="https://api.rendex.dev")`
|
|
60
|
+
|
|
61
|
+
Create a new Rendex client.
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
rendex = Rendex("your-api-key")
|
|
65
|
+
|
|
66
|
+
# Or with context manager for connection reuse
|
|
67
|
+
with Rendex("your-api-key") as rendex:
|
|
68
|
+
result = rendex.screenshot("https://example.com")
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### `rendex.screenshot(url, **options)`
|
|
72
|
+
|
|
73
|
+
Capture a screenshot and return the binary image with metadata.
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
result = rendex.screenshot(
|
|
77
|
+
"https://example.com",
|
|
78
|
+
format="webp",
|
|
79
|
+
width=1920,
|
|
80
|
+
height=1080,
|
|
81
|
+
dark_mode=True,
|
|
82
|
+
)
|
|
83
|
+
Path("screenshot.webp").write_bytes(result.image)
|
|
84
|
+
print(result.metadata.load_time_ms) # 350
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
**Returns** `ScreenshotResult`:
|
|
88
|
+
- `image` — `bytes` of the captured image
|
|
89
|
+
- `metadata` — `ScreenshotMetadata` with url, dimensions, format, bytes_size, load_time_ms, quality, etc.
|
|
90
|
+
|
|
91
|
+
### `rendex.screenshot_json(url, **options)`
|
|
92
|
+
|
|
93
|
+
Capture a screenshot and return JSON with a base64-encoded image.
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
result = rendex.screenshot_json("https://example.com")
|
|
97
|
+
print(result["data"]["bytesSize"]) # 45823
|
|
98
|
+
print(result["meta"]["usage"]["remaining"]) # 499
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**Returns** `ScreenshotJsonResponse` dict with `data` (image + metadata) and `meta` (request ID, usage).
|
|
102
|
+
|
|
103
|
+
### `rendex.screenshot_url(url, **options)`
|
|
104
|
+
|
|
105
|
+
Generate a GET URL for embedding. No network call — pure URL builder.
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
url = rendex.screenshot_url("https://example.com", format="png", width=1200)
|
|
109
|
+
# Use in <img> tags, OpenGraph, etc.
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
> **Note**: The API key is included in the URL. Use server-side only.
|
|
113
|
+
|
|
114
|
+
### Screenshot Options
|
|
115
|
+
|
|
116
|
+
All options are keyword arguments in snake_case. Only `url` (positional) is required:
|
|
117
|
+
|
|
118
|
+
| Option | Type | Default | Description |
|
|
119
|
+
|--------|------|---------|-------------|
|
|
120
|
+
| `format` | `str` | `"png"` | `"png"`, `"jpeg"`, or `"webp"` |
|
|
121
|
+
| `width` | `int` | `1280` | Viewport width (320–3840) |
|
|
122
|
+
| `height` | `int` | `800` | Viewport height (240–2160) |
|
|
123
|
+
| `full_page` | `bool` | `False` | Capture the full scrollable page |
|
|
124
|
+
| `quality` | `int` | — | JPEG/WebP quality (1–100) |
|
|
125
|
+
| `delay` | `int` | `0` | Delay before capture in ms (0–10000) |
|
|
126
|
+
| `dark_mode` | `bool` | `False` | Emulate dark mode |
|
|
127
|
+
| `device_scale_factor` | `float` | `1` | Device pixel ratio (1–3) for Retina |
|
|
128
|
+
| `block_ads` | `bool` | `True` | Block ads and trackers |
|
|
129
|
+
| `block_resource_types` | `list` | — | Block: `"font"`, `"image"`, `"media"`, `"stylesheet"`, `"other"` |
|
|
130
|
+
| `timeout` | `int` | `30` | Page load timeout in seconds (5–60) |
|
|
131
|
+
| `wait_until` | `str` | `"networkidle2"` | `"load"`, `"domcontentloaded"`, `"networkidle0"`, `"networkidle2"` |
|
|
132
|
+
| `wait_for_selector` | `str` | — | CSS selector to wait for |
|
|
133
|
+
| `best_attempt` | `bool` | `True` | Return best-effort screenshot on timeout |
|
|
134
|
+
| `selector` | `str` | — | Capture a specific element by CSS selector |
|
|
135
|
+
|
|
136
|
+
## Error Handling
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from rendex import Rendex, RendexApiError, RendexNetworkError
|
|
140
|
+
|
|
141
|
+
rendex = Rendex("your-api-key")
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
rendex.screenshot("https://example.com")
|
|
145
|
+
except RendexApiError as e:
|
|
146
|
+
# API returned an error
|
|
147
|
+
print(e.error_code) # "RATE_LIMITED", "VALIDATION_ERROR", etc.
|
|
148
|
+
print(e.status_code) # 429, 400, etc.
|
|
149
|
+
print(e.request_id) # For debugging with Rendex support
|
|
150
|
+
print(e.details) # Validation details (if any)
|
|
151
|
+
except RendexNetworkError as e:
|
|
152
|
+
# Network failure (DNS, timeout, connection refused)
|
|
153
|
+
print(f"Network error: {e}")
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### Error Codes
|
|
157
|
+
|
|
158
|
+
| Code | HTTP Status | Description |
|
|
159
|
+
|------|-------------|-------------|
|
|
160
|
+
| `VALIDATION_ERROR` | 400 | Invalid request parameters |
|
|
161
|
+
| `INVALID_URL` | 400 | URL failed SSRF validation |
|
|
162
|
+
| `TIMEOUT` | 408 | Page took too long to load |
|
|
163
|
+
| `CAPTURE_FAILED` | 500 | Browser rendering error |
|
|
164
|
+
| `RATE_LIMITED` | 429 | Rate limit exceeded |
|
|
165
|
+
| `USAGE_EXCEEDED` | 429 | Monthly credit limit reached |
|
|
166
|
+
| `MISSING_API_KEY` | 401 | No API key provided |
|
|
167
|
+
| `INVALID_API_KEY` | 401 | API key verification failed |
|
|
168
|
+
|
|
169
|
+
## License
|
|
170
|
+
|
|
171
|
+
MIT - [Copperline Labs LLC](https://copperlinelabs.com)
|
rendex-0.1.0/README.md
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# rendex
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the [Rendex](https://rendex.dev) screenshot API. Capture any webpage as a high-quality image with a single function call.
|
|
4
|
+
|
|
5
|
+
- Full type hints (PEP 561 compatible)
|
|
6
|
+
- Single dependency (`httpx`)
|
|
7
|
+
- Sync API with context manager support
|
|
8
|
+
- Typed error handling with API error codes
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install rendex
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Quick Start
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from rendex import Rendex
|
|
21
|
+
|
|
22
|
+
rendex = Rendex("your-api-key")
|
|
23
|
+
|
|
24
|
+
# Capture a screenshot (returns binary image)
|
|
25
|
+
result = rendex.screenshot("https://example.com", format="png", full_page=True)
|
|
26
|
+
Path("screenshot.png").write_bytes(result.image)
|
|
27
|
+
|
|
28
|
+
print(f"{result.metadata.bytes_size} bytes, loaded in {result.metadata.load_time_ms}ms")
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## API Reference
|
|
32
|
+
|
|
33
|
+
### `Rendex(api_key, *, base_url="https://api.rendex.dev")`
|
|
34
|
+
|
|
35
|
+
Create a new Rendex client.
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
rendex = Rendex("your-api-key")
|
|
39
|
+
|
|
40
|
+
# Or with context manager for connection reuse
|
|
41
|
+
with Rendex("your-api-key") as rendex:
|
|
42
|
+
result = rendex.screenshot("https://example.com")
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### `rendex.screenshot(url, **options)`
|
|
46
|
+
|
|
47
|
+
Capture a screenshot and return the binary image with metadata.
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
result = rendex.screenshot(
|
|
51
|
+
"https://example.com",
|
|
52
|
+
format="webp",
|
|
53
|
+
width=1920,
|
|
54
|
+
height=1080,
|
|
55
|
+
dark_mode=True,
|
|
56
|
+
)
|
|
57
|
+
Path("screenshot.webp").write_bytes(result.image)
|
|
58
|
+
print(result.metadata.load_time_ms) # 350
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
**Returns** `ScreenshotResult`:
|
|
62
|
+
- `image` — `bytes` of the captured image
|
|
63
|
+
- `metadata` — `ScreenshotMetadata` with url, dimensions, format, bytes_size, load_time_ms, quality, etc.
|
|
64
|
+
|
|
65
|
+
### `rendex.screenshot_json(url, **options)`
|
|
66
|
+
|
|
67
|
+
Capture a screenshot and return JSON with a base64-encoded image.
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
result = rendex.screenshot_json("https://example.com")
|
|
71
|
+
print(result["data"]["bytesSize"]) # 45823
|
|
72
|
+
print(result["meta"]["usage"]["remaining"]) # 499
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Returns** `ScreenshotJsonResponse` dict with `data` (image + metadata) and `meta` (request ID, usage).
|
|
76
|
+
|
|
77
|
+
### `rendex.screenshot_url(url, **options)`
|
|
78
|
+
|
|
79
|
+
Generate a GET URL for embedding. No network call — pure URL builder.
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
url = rendex.screenshot_url("https://example.com", format="png", width=1200)
|
|
83
|
+
# Use in <img> tags, OpenGraph, etc.
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
> **Note**: The API key is included in the URL. Use server-side only.
|
|
87
|
+
|
|
88
|
+
### Screenshot Options
|
|
89
|
+
|
|
90
|
+
All options are keyword arguments in snake_case. Only `url` (positional) is required:
|
|
91
|
+
|
|
92
|
+
| Option | Type | Default | Description |
|
|
93
|
+
|--------|------|---------|-------------|
|
|
94
|
+
| `format` | `str` | `"png"` | `"png"`, `"jpeg"`, or `"webp"` |
|
|
95
|
+
| `width` | `int` | `1280` | Viewport width (320–3840) |
|
|
96
|
+
| `height` | `int` | `800` | Viewport height (240–2160) |
|
|
97
|
+
| `full_page` | `bool` | `False` | Capture the full scrollable page |
|
|
98
|
+
| `quality` | `int` | — | JPEG/WebP quality (1–100) |
|
|
99
|
+
| `delay` | `int` | `0` | Delay before capture in ms (0–10000) |
|
|
100
|
+
| `dark_mode` | `bool` | `False` | Emulate dark mode |
|
|
101
|
+
| `device_scale_factor` | `float` | `1` | Device pixel ratio (1–3) for Retina |
|
|
102
|
+
| `block_ads` | `bool` | `True` | Block ads and trackers |
|
|
103
|
+
| `block_resource_types` | `list` | — | Block: `"font"`, `"image"`, `"media"`, `"stylesheet"`, `"other"` |
|
|
104
|
+
| `timeout` | `int` | `30` | Page load timeout in seconds (5–60) |
|
|
105
|
+
| `wait_until` | `str` | `"networkidle2"` | `"load"`, `"domcontentloaded"`, `"networkidle0"`, `"networkidle2"` |
|
|
106
|
+
| `wait_for_selector` | `str` | — | CSS selector to wait for |
|
|
107
|
+
| `best_attempt` | `bool` | `True` | Return best-effort screenshot on timeout |
|
|
108
|
+
| `selector` | `str` | — | Capture a specific element by CSS selector |
|
|
109
|
+
|
|
110
|
+
## Error Handling
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from rendex import Rendex, RendexApiError, RendexNetworkError
|
|
114
|
+
|
|
115
|
+
rendex = Rendex("your-api-key")
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
rendex.screenshot("https://example.com")
|
|
119
|
+
except RendexApiError as e:
|
|
120
|
+
# API returned an error
|
|
121
|
+
print(e.error_code) # "RATE_LIMITED", "VALIDATION_ERROR", etc.
|
|
122
|
+
print(e.status_code) # 429, 400, etc.
|
|
123
|
+
print(e.request_id) # For debugging with Rendex support
|
|
124
|
+
print(e.details) # Validation details (if any)
|
|
125
|
+
except RendexNetworkError as e:
|
|
126
|
+
# Network failure (DNS, timeout, connection refused)
|
|
127
|
+
print(f"Network error: {e}")
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Error Codes
|
|
131
|
+
|
|
132
|
+
| Code | HTTP Status | Description |
|
|
133
|
+
|------|-------------|-------------|
|
|
134
|
+
| `VALIDATION_ERROR` | 400 | Invalid request parameters |
|
|
135
|
+
| `INVALID_URL` | 400 | URL failed SSRF validation |
|
|
136
|
+
| `TIMEOUT` | 408 | Page took too long to load |
|
|
137
|
+
| `CAPTURE_FAILED` | 500 | Browser rendering error |
|
|
138
|
+
| `RATE_LIMITED` | 429 | Rate limit exceeded |
|
|
139
|
+
| `USAGE_EXCEEDED` | 429 | Monthly credit limit reached |
|
|
140
|
+
| `MISSING_API_KEY` | 401 | No API key provided |
|
|
141
|
+
| `INVALID_API_KEY` | 401 | API key verification failed |
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
MIT - [Copperline Labs LLC](https://copperlinelabs.com)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "rendex"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for the Rendex screenshot API — capture any webpage as an image"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [{ name = "Copperline Labs LLC", email = "support@rendex.dev" }]
|
|
13
|
+
keywords = ["rendex", "screenshot", "screenshot-api", "webpage-capture", "sdk", "ai-agent"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.9",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Typing :: Typed",
|
|
25
|
+
]
|
|
26
|
+
dependencies = ["httpx>=0.27"]
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://rendex.dev"
|
|
30
|
+
Documentation = "https://rendex.dev/docs"
|
|
31
|
+
Repository = "https://github.com/copperline-labs/copperline-labs"
|
|
32
|
+
Issues = "https://github.com/copperline-labs/copperline-labs/issues"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/rendex"]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Rendex — Official Python SDK for the Rendex screenshot API."""
|
|
2
|
+
|
|
3
|
+
from .client import Rendex
|
|
4
|
+
from .errors import RendexApiError, RendexError, RendexNetworkError
|
|
5
|
+
from .types import (
|
|
6
|
+
ScreenshotData,
|
|
7
|
+
ScreenshotJsonResponse,
|
|
8
|
+
ScreenshotMetadata,
|
|
9
|
+
ScreenshotOptions,
|
|
10
|
+
ScreenshotResult,
|
|
11
|
+
ResponseMeta,
|
|
12
|
+
UsageInfo,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Rendex",
|
|
19
|
+
"RendexError",
|
|
20
|
+
"RendexApiError",
|
|
21
|
+
"RendexNetworkError",
|
|
22
|
+
"ScreenshotOptions",
|
|
23
|
+
"ScreenshotResult",
|
|
24
|
+
"ScreenshotMetadata",
|
|
25
|
+
"ScreenshotJsonResponse",
|
|
26
|
+
"ScreenshotData",
|
|
27
|
+
"ResponseMeta",
|
|
28
|
+
"UsageInfo",
|
|
29
|
+
]
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Rendex SDK client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import urlencode
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .errors import RendexApiError, RendexError, RendexNetworkError
|
|
11
|
+
from .types import (
|
|
12
|
+
ScreenshotJsonResponse,
|
|
13
|
+
ScreenshotMetadata,
|
|
14
|
+
ScreenshotResult,
|
|
15
|
+
_build_body,
|
|
16
|
+
_to_camel,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
DEFAULT_BASE_URL = "https://api.rendex.dev"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Rendex:
|
|
23
|
+
"""Official Python client for the Rendex screenshot API.
|
|
24
|
+
|
|
25
|
+
Usage::
|
|
26
|
+
|
|
27
|
+
from rendex import Rendex
|
|
28
|
+
|
|
29
|
+
rendex = Rendex("your-api-key")
|
|
30
|
+
result = rendex.screenshot("https://example.com", full_page=True)
|
|
31
|
+
Path("screenshot.png").write_bytes(result.image)
|
|
32
|
+
|
|
33
|
+
Or as a context manager::
|
|
34
|
+
|
|
35
|
+
with Rendex("your-api-key") as rendex:
|
|
36
|
+
result = rendex.screenshot("https://example.com")
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, api_key: str, *, base_url: str = DEFAULT_BASE_URL) -> None:
|
|
40
|
+
if not api_key:
|
|
41
|
+
raise RendexError("API key is required. Get one at https://rendex.dev")
|
|
42
|
+
self._api_key = api_key
|
|
43
|
+
self._base_url = base_url.rstrip("/")
|
|
44
|
+
self._client = httpx.Client(
|
|
45
|
+
base_url=self._base_url,
|
|
46
|
+
headers={
|
|
47
|
+
"Authorization": f"Bearer {api_key}",
|
|
48
|
+
"Content-Type": "application/json",
|
|
49
|
+
},
|
|
50
|
+
timeout=90.0,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def screenshot(self, url: str, **options: Any) -> ScreenshotResult:
|
|
54
|
+
"""Capture a screenshot and return the binary image with metadata.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
url: The webpage URL to capture.
|
|
58
|
+
**options: Screenshot options (snake_case). See ScreenshotOptions.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
ScreenshotResult with ``image`` (bytes) and ``metadata``.
|
|
62
|
+
"""
|
|
63
|
+
body = _build_body(url, options)
|
|
64
|
+
response = self._request("/v1/screenshot", body)
|
|
65
|
+
|
|
66
|
+
metadata = self._parse_metadata_headers(response.headers, len(response.content))
|
|
67
|
+
|
|
68
|
+
return ScreenshotResult(image=response.content, metadata=metadata)
|
|
69
|
+
|
|
70
|
+
def screenshot_json(self, url: str, **options: Any) -> ScreenshotJsonResponse:
|
|
71
|
+
"""Capture a screenshot and return JSON with a base64-encoded image.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
url: The webpage URL to capture.
|
|
75
|
+
**options: Screenshot options (snake_case). See ScreenshotOptions.
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
ScreenshotJsonResponse with ``data`` and ``meta``.
|
|
79
|
+
"""
|
|
80
|
+
body = _build_body(url, options)
|
|
81
|
+
response = self._request("/v1/screenshot/json", body)
|
|
82
|
+
data = response.json()
|
|
83
|
+
|
|
84
|
+
if not data.get("success"):
|
|
85
|
+
error = data.get("error", {})
|
|
86
|
+
meta = data.get("meta", {})
|
|
87
|
+
raise RendexApiError(
|
|
88
|
+
message=error.get("message", "Unknown error"),
|
|
89
|
+
status_code=response.status_code,
|
|
90
|
+
error_code=error.get("code", "UNKNOWN"),
|
|
91
|
+
request_id=meta.get("requestId"),
|
|
92
|
+
details=error.get("details"),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return data # type: ignore[return-value]
|
|
96
|
+
|
|
97
|
+
def screenshot_url(self, url: str, **options: Any) -> str:
|
|
98
|
+
"""Generate a signed GET URL for embedding (no network call).
|
|
99
|
+
|
|
100
|
+
Useful for ``<img>`` tags, OpenGraph images, or anywhere you need a URL.
|
|
101
|
+
|
|
102
|
+
**Note**: The API key is included in the URL. Use server-side only.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
url: The webpage URL to capture.
|
|
106
|
+
**options: Screenshot options (snake_case).
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
A complete URL string to the screenshot endpoint.
|
|
110
|
+
"""
|
|
111
|
+
params: list[tuple[str, str]] = [("key", self._api_key), ("url", url)]
|
|
112
|
+
|
|
113
|
+
for key, value in options.items():
|
|
114
|
+
if value is None:
|
|
115
|
+
continue
|
|
116
|
+
camel_key = _to_camel(key)
|
|
117
|
+
if isinstance(value, list):
|
|
118
|
+
for item in value:
|
|
119
|
+
params.append((camel_key, str(item)))
|
|
120
|
+
elif isinstance(value, bool):
|
|
121
|
+
params.append((camel_key, str(value).lower()))
|
|
122
|
+
else:
|
|
123
|
+
params.append((camel_key, str(value)))
|
|
124
|
+
|
|
125
|
+
return f"{self._base_url}/v1/screenshot?{urlencode(params)}"
|
|
126
|
+
|
|
127
|
+
def close(self) -> None:
|
|
128
|
+
"""Close the underlying HTTP client."""
|
|
129
|
+
self._client.close()
|
|
130
|
+
|
|
131
|
+
def __enter__(self) -> Rendex:
|
|
132
|
+
return self
|
|
133
|
+
|
|
134
|
+
def __exit__(self, *args: Any) -> None:
|
|
135
|
+
self.close()
|
|
136
|
+
|
|
137
|
+
# ─── Private ─────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
def _request(self, path: str, body: dict[str, Any]) -> httpx.Response:
|
|
140
|
+
try:
|
|
141
|
+
response = self._client.post(path, json=body)
|
|
142
|
+
except httpx.HTTPError as exc:
|
|
143
|
+
raise RendexNetworkError(
|
|
144
|
+
f"Network request to {self._base_url}{path} failed: {exc}",
|
|
145
|
+
cause=exc,
|
|
146
|
+
) from exc
|
|
147
|
+
|
|
148
|
+
if response.status_code >= 400:
|
|
149
|
+
self._handle_error_response(response)
|
|
150
|
+
|
|
151
|
+
return response
|
|
152
|
+
|
|
153
|
+
def _handle_error_response(self, response: httpx.Response) -> None:
|
|
154
|
+
try:
|
|
155
|
+
data = response.json()
|
|
156
|
+
except Exception:
|
|
157
|
+
raise RendexApiError(
|
|
158
|
+
message=f"HTTP {response.status_code}",
|
|
159
|
+
status_code=response.status_code,
|
|
160
|
+
error_code="UNKNOWN",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
error = data.get("error", {})
|
|
164
|
+
meta = data.get("meta", {})
|
|
165
|
+
|
|
166
|
+
raise RendexApiError(
|
|
167
|
+
message=error.get("message", f"HTTP {response.status_code}"),
|
|
168
|
+
status_code=response.status_code,
|
|
169
|
+
error_code=error.get("code", "UNKNOWN"),
|
|
170
|
+
request_id=meta.get("requestId"),
|
|
171
|
+
details=error.get("details"),
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
@staticmethod
|
|
175
|
+
def _parse_metadata_headers(headers: httpx.Headers, fallback_size: int) -> ScreenshotMetadata:
|
|
176
|
+
return ScreenshotMetadata(
|
|
177
|
+
url=headers.get("x-screenshot-url", ""),
|
|
178
|
+
width=int(headers.get("x-screenshot-width", "0")),
|
|
179
|
+
height=int(headers.get("x-screenshot-height", "0")),
|
|
180
|
+
format=headers.get("x-rendex-format", "png"),
|
|
181
|
+
bytes_size=int(headers.get("x-screenshot-size", str(fallback_size))),
|
|
182
|
+
captured_at=headers.get("x-screenshot-captured-at", ""),
|
|
183
|
+
quality=headers.get("x-rendex-quality", "full"),
|
|
184
|
+
wait_strategy=headers.get("x-rendex-wait-strategy", "unknown"),
|
|
185
|
+
load_time_ms=int(headers.get("x-rendex-load-time-ms", "0")),
|
|
186
|
+
truncated=headers.get("x-rendex-truncated") == "true",
|
|
187
|
+
)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Rendex SDK error classes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RendexError(Exception):
|
|
9
|
+
"""Base error for all Rendex SDK errors."""
|
|
10
|
+
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RendexApiError(RendexError):
|
|
15
|
+
"""Error returned by the Rendex API (non-2xx response with a parsed body)."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
message: str,
|
|
20
|
+
status_code: int,
|
|
21
|
+
error_code: str,
|
|
22
|
+
request_id: Optional[str] = None,
|
|
23
|
+
details: Any = None,
|
|
24
|
+
) -> None:
|
|
25
|
+
super().__init__(message)
|
|
26
|
+
self.status_code = status_code
|
|
27
|
+
"""HTTP status code (e.g. 400, 401, 429, 500)."""
|
|
28
|
+
self.error_code = error_code
|
|
29
|
+
"""API error code (e.g. "RATE_LIMITED", "VALIDATION_ERROR")."""
|
|
30
|
+
self.request_id = request_id
|
|
31
|
+
"""Unique request ID for debugging with Rendex support."""
|
|
32
|
+
self.details = details
|
|
33
|
+
"""Additional error details (e.g. validation field errors)."""
|
|
34
|
+
|
|
35
|
+
def __str__(self) -> str:
|
|
36
|
+
parts = [f"[{self.error_code}] {super().__str__()}"]
|
|
37
|
+
if self.request_id:
|
|
38
|
+
parts.append(f"(request: {self.request_id})")
|
|
39
|
+
return " ".join(parts)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RendexNetworkError(RendexError):
|
|
43
|
+
"""Network-level error (DNS failure, timeout, connection refused, etc.)."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, message: str, cause: Optional[Exception] = None) -> None:
|
|
46
|
+
super().__init__(message)
|
|
47
|
+
self.__cause__ = cause
|
|
File without changes
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Rendex SDK type definitions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any, List, Literal, Optional, TypedDict
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ScreenshotOptions(TypedDict, total=False):
|
|
10
|
+
"""Options for capturing a screenshot. Only ``url`` is required."""
|
|
11
|
+
|
|
12
|
+
url: str # Required — set via positional arg in client methods
|
|
13
|
+
format: Literal["png", "jpeg", "webp"]
|
|
14
|
+
width: int
|
|
15
|
+
height: int
|
|
16
|
+
full_page: bool
|
|
17
|
+
quality: int
|
|
18
|
+
delay: int
|
|
19
|
+
dark_mode: bool
|
|
20
|
+
device_scale_factor: float
|
|
21
|
+
block_ads: bool
|
|
22
|
+
block_resource_types: List[Literal["font", "image", "media", "stylesheet", "other"]]
|
|
23
|
+
timeout: int
|
|
24
|
+
wait_until: Literal["load", "domcontentloaded", "networkidle0", "networkidle2"]
|
|
25
|
+
wait_for_selector: str
|
|
26
|
+
best_attempt: bool
|
|
27
|
+
selector: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class UsageInfo(TypedDict):
|
|
31
|
+
credits: int
|
|
32
|
+
remaining: int
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ResponseMeta(TypedDict, total=False):
|
|
36
|
+
requestId: str
|
|
37
|
+
timestamp: str
|
|
38
|
+
usage: UsageInfo
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ScreenshotData(TypedDict, total=False):
|
|
42
|
+
image: str # base64
|
|
43
|
+
contentType: str
|
|
44
|
+
url: str
|
|
45
|
+
width: int
|
|
46
|
+
height: int
|
|
47
|
+
format: str
|
|
48
|
+
bytesSize: int
|
|
49
|
+
capturedAt: str
|
|
50
|
+
quality: Literal["full", "degraded", "best_attempt"]
|
|
51
|
+
waitStrategy: str
|
|
52
|
+
loadTimeMs: int
|
|
53
|
+
truncated: bool
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ScreenshotJsonResponse(TypedDict):
|
|
57
|
+
success: bool
|
|
58
|
+
data: ScreenshotData
|
|
59
|
+
meta: ResponseMeta
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class ScreenshotMetadata:
|
|
64
|
+
"""Metadata from response headers for binary screenshots."""
|
|
65
|
+
|
|
66
|
+
url: str
|
|
67
|
+
width: int
|
|
68
|
+
height: int
|
|
69
|
+
format: str
|
|
70
|
+
bytes_size: int
|
|
71
|
+
captured_at: str
|
|
72
|
+
quality: str # "full", "degraded", or "best_attempt"
|
|
73
|
+
wait_strategy: str
|
|
74
|
+
load_time_ms: int
|
|
75
|
+
truncated: bool
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class ScreenshotResult:
|
|
80
|
+
"""Result from ``Rendex.screenshot()`` — binary image with metadata."""
|
|
81
|
+
|
|
82
|
+
image: bytes
|
|
83
|
+
"""Raw image bytes. Write to file, upload to S3, etc."""
|
|
84
|
+
|
|
85
|
+
metadata: ScreenshotMetadata
|
|
86
|
+
"""Capture metadata extracted from response headers."""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# camelCase conversion for API requests
|
|
90
|
+
def _to_camel(key: str) -> str:
|
|
91
|
+
"""Convert snake_case to camelCase."""
|
|
92
|
+
parts = key.split("_")
|
|
93
|
+
return parts[0] + "".join(p.capitalize() for p in parts[1:])
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _build_body(url: str, options: Any) -> dict[str, Any]:
|
|
97
|
+
"""Build the JSON request body from url + snake_case kwargs."""
|
|
98
|
+
body: dict[str, Any] = {"url": url}
|
|
99
|
+
for key, value in options.items():
|
|
100
|
+
if value is not None:
|
|
101
|
+
body[_to_camel(key)] = value
|
|
102
|
+
return body
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Tests for the Rendex Python SDK."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from unittest.mock import MagicMock
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from rendex import Rendex, RendexApiError, RendexError, RendexNetworkError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ─── Constructor ──────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_missing_api_key():
|
|
16
|
+
with pytest.raises(RendexError, match="API key is required"):
|
|
17
|
+
Rendex("")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_custom_base_url():
|
|
21
|
+
client = Rendex("test-key", base_url="https://custom.api.dev/")
|
|
22
|
+
assert client._base_url == "https://custom.api.dev"
|
|
23
|
+
client.close()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ─── screenshot_url ───────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_screenshot_url_basic():
|
|
30
|
+
client = Rendex("test-key")
|
|
31
|
+
url = client.screenshot_url("https://example.com")
|
|
32
|
+
assert "key=test-key" in url
|
|
33
|
+
assert "url=https" in url
|
|
34
|
+
assert url.startswith("https://api.rendex.dev/v1/screenshot?")
|
|
35
|
+
client.close()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_screenshot_url_with_options():
|
|
39
|
+
client = Rendex("test-key")
|
|
40
|
+
url = client.screenshot_url(
|
|
41
|
+
"https://example.com",
|
|
42
|
+
format="webp",
|
|
43
|
+
full_page=True,
|
|
44
|
+
width=1920,
|
|
45
|
+
)
|
|
46
|
+
assert "format=webp" in url
|
|
47
|
+
assert "fullPage=true" in url
|
|
48
|
+
assert "width=1920" in url
|
|
49
|
+
client.close()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_screenshot_url_array_params():
|
|
53
|
+
client = Rendex("test-key")
|
|
54
|
+
url = client.screenshot_url(
|
|
55
|
+
"https://example.com",
|
|
56
|
+
block_resource_types=["font", "image"],
|
|
57
|
+
)
|
|
58
|
+
assert "blockResourceTypes=font" in url
|
|
59
|
+
assert "blockResourceTypes=image" in url
|
|
60
|
+
client.close()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ─── camelCase conversion ────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_to_camel():
|
|
67
|
+
from rendex.types import _to_camel
|
|
68
|
+
|
|
69
|
+
assert _to_camel("full_page") == "fullPage"
|
|
70
|
+
assert _to_camel("dark_mode") == "darkMode"
|
|
71
|
+
assert _to_camel("device_scale_factor") == "deviceScaleFactor"
|
|
72
|
+
assert _to_camel("url") == "url"
|
|
73
|
+
assert _to_camel("block_resource_types") == "blockResourceTypes"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_build_body():
|
|
77
|
+
from rendex.types import _build_body
|
|
78
|
+
|
|
79
|
+
body = _build_body("https://example.com", {"full_page": True, "dark_mode": False, "quality": None})
|
|
80
|
+
assert body == {"url": "https://example.com", "fullPage": True, "darkMode": False}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ─── Error handling ──────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_api_error_str():
|
|
87
|
+
err = RendexApiError(
|
|
88
|
+
message="Rate limit exceeded",
|
|
89
|
+
status_code=429,
|
|
90
|
+
error_code="RATE_LIMITED",
|
|
91
|
+
request_id="req-123",
|
|
92
|
+
)
|
|
93
|
+
assert "[RATE_LIMITED]" in str(err)
|
|
94
|
+
assert "req-123" in str(err)
|
|
95
|
+
assert err.status_code == 429
|
|
96
|
+
assert err.error_code == "RATE_LIMITED"
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_api_error_no_request_id():
|
|
100
|
+
err = RendexApiError(
|
|
101
|
+
message="Bad request",
|
|
102
|
+
status_code=400,
|
|
103
|
+
error_code="VALIDATION_ERROR",
|
|
104
|
+
)
|
|
105
|
+
assert "[VALIDATION_ERROR]" in str(err)
|
|
106
|
+
assert "request:" not in str(err)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ─── Context manager ────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_context_manager():
|
|
113
|
+
with Rendex("test-key") as client:
|
|
114
|
+
assert client._api_key == "test-key"
|
|
115
|
+
# Should not raise after close
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ─── Mocked HTTP tests ──────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_screenshot_json_success():
|
|
122
|
+
"""Test screenshotJson with a mocked transport."""
|
|
123
|
+
mock_response_data = {
|
|
124
|
+
"success": True,
|
|
125
|
+
"data": {
|
|
126
|
+
"image": "iVBORw0KGgo=",
|
|
127
|
+
"contentType": "image/png",
|
|
128
|
+
"url": "https://example.com",
|
|
129
|
+
"width": 1280,
|
|
130
|
+
"height": 800,
|
|
131
|
+
"format": "png",
|
|
132
|
+
"bytesSize": 1234,
|
|
133
|
+
"capturedAt": "2026-04-02T00:00:00Z",
|
|
134
|
+
"quality": "full",
|
|
135
|
+
"waitStrategy": "networkidle2",
|
|
136
|
+
"loadTimeMs": 500,
|
|
137
|
+
},
|
|
138
|
+
"meta": {
|
|
139
|
+
"requestId": "req-abc",
|
|
140
|
+
"timestamp": "2026-04-02T00:00:00Z",
|
|
141
|
+
"usage": {"credits": 1, "remaining": 499},
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
transport = httpx.MockTransport(
|
|
146
|
+
lambda request: httpx.Response(
|
|
147
|
+
200,
|
|
148
|
+
json=mock_response_data,
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
client = Rendex("test-key")
|
|
153
|
+
client._client = httpx.Client(
|
|
154
|
+
base_url="https://api.rendex.dev",
|
|
155
|
+
headers={"Authorization": "Bearer test-key", "Content-Type": "application/json"},
|
|
156
|
+
transport=transport,
|
|
157
|
+
timeout=90.0,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
result = client.screenshot_json("https://example.com")
|
|
161
|
+
assert result["success"] is True
|
|
162
|
+
assert result["data"]["bytesSize"] == 1234
|
|
163
|
+
assert result["meta"]["usage"]["remaining"] == 499
|
|
164
|
+
client.close()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_screenshot_binary_success():
|
|
168
|
+
"""Test screenshot with mocked binary response + headers."""
|
|
169
|
+
fake_image = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100
|
|
170
|
+
|
|
171
|
+
transport = httpx.MockTransport(
|
|
172
|
+
lambda request: httpx.Response(
|
|
173
|
+
200,
|
|
174
|
+
content=fake_image,
|
|
175
|
+
headers={
|
|
176
|
+
"content-type": "image/png",
|
|
177
|
+
"x-screenshot-url": "https://example.com",
|
|
178
|
+
"x-screenshot-width": "1280",
|
|
179
|
+
"x-screenshot-height": "800",
|
|
180
|
+
"x-screenshot-size": str(len(fake_image)),
|
|
181
|
+
"x-screenshot-captured-at": "2026-04-02T00:00:00Z",
|
|
182
|
+
"x-rendex-format": "png",
|
|
183
|
+
"x-rendex-quality": "full",
|
|
184
|
+
"x-rendex-wait-strategy": "networkidle2",
|
|
185
|
+
"x-rendex-load-time-ms": "350",
|
|
186
|
+
},
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
client = Rendex("test-key")
|
|
191
|
+
client._client = httpx.Client(
|
|
192
|
+
base_url="https://api.rendex.dev",
|
|
193
|
+
headers={"Authorization": "Bearer test-key", "Content-Type": "application/json"},
|
|
194
|
+
transport=transport,
|
|
195
|
+
timeout=90.0,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
result = client.screenshot("https://example.com")
|
|
199
|
+
assert result.image == fake_image
|
|
200
|
+
assert result.metadata.width == 1280
|
|
201
|
+
assert result.metadata.format == "png"
|
|
202
|
+
assert result.metadata.load_time_ms == 350
|
|
203
|
+
assert result.metadata.truncated is False
|
|
204
|
+
client.close()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_api_error_response():
|
|
208
|
+
"""Test that API errors are properly parsed."""
|
|
209
|
+
error_body = {
|
|
210
|
+
"success": False,
|
|
211
|
+
"error": {
|
|
212
|
+
"code": "RATE_LIMITED",
|
|
213
|
+
"message": "Rate limit exceeded",
|
|
214
|
+
},
|
|
215
|
+
"meta": {
|
|
216
|
+
"requestId": "req-xyz",
|
|
217
|
+
"timestamp": "2026-04-02T00:00:00Z",
|
|
218
|
+
},
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
transport = httpx.MockTransport(
|
|
222
|
+
lambda request: httpx.Response(429, json=error_body)
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
client = Rendex("test-key")
|
|
226
|
+
client._client = httpx.Client(
|
|
227
|
+
base_url="https://api.rendex.dev",
|
|
228
|
+
headers={"Authorization": "Bearer test-key", "Content-Type": "application/json"},
|
|
229
|
+
transport=transport,
|
|
230
|
+
timeout=90.0,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
with pytest.raises(RendexApiError) as exc_info:
|
|
234
|
+
client.screenshot("https://example.com")
|
|
235
|
+
|
|
236
|
+
assert exc_info.value.status_code == 429
|
|
237
|
+
assert exc_info.value.error_code == "RATE_LIMITED"
|
|
238
|
+
assert exc_info.value.request_id == "req-xyz"
|
|
239
|
+
client.close()
|