sprntrl 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.
- sprntrl-0.1.0/.gitignore +12 -0
- sprntrl-0.1.0/LICENSE +21 -0
- sprntrl-0.1.0/PKG-INFO +152 -0
- sprntrl-0.1.0/README.md +117 -0
- sprntrl-0.1.0/pyproject.toml +64 -0
- sprntrl-0.1.0/sprntrl/__init__.py +37 -0
- sprntrl-0.1.0/sprntrl/_base_client.py +232 -0
- sprntrl-0.1.0/sprntrl/_client.py +59 -0
- sprntrl-0.1.0/sprntrl/_errors.py +65 -0
- sprntrl-0.1.0/sprntrl/_types.py +161 -0
- sprntrl-0.1.0/sprntrl/_utils.py +16 -0
- sprntrl-0.1.0/sprntrl/lib/__init__.py +0 -0
- sprntrl-0.1.0/sprntrl/lib/browser.py +105 -0
- sprntrl-0.1.0/sprntrl/py.typed +0 -0
- sprntrl-0.1.0/sprntrl/resources/__init__.py +24 -0
- sprntrl-0.1.0/sprntrl/resources/api_keys.py +43 -0
- sprntrl-0.1.0/sprntrl/resources/files.py +89 -0
- sprntrl-0.1.0/sprntrl/resources/ip_whitelist.py +56 -0
- sprntrl-0.1.0/sprntrl/resources/profiles.py +95 -0
- sprntrl-0.1.0/sprntrl/resources/sessions.py +419 -0
- sprntrl-0.1.0/sprntrl/resources/templates.py +26 -0
- sprntrl-0.1.0/sprntrl/resources/usage.py +32 -0
- sprntrl-0.1.0/sprntrl/resources/user.py +70 -0
sprntrl-0.1.0/.gitignore
ADDED
sprntrl-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Supernatural
|
|
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.
|
sprntrl-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sprntrl
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Supernatural Python SDK — stealth browser-as-a-service client.
|
|
5
|
+
Project-URL: Homepage, https://supernatural.sh
|
|
6
|
+
Project-URL: Repository, https://github.com/supernatural-browser/sprntrl-python
|
|
7
|
+
Project-URL: Documentation, https://app.supernatural.sh/docs
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/supernatural-browser/sprntrl-python/issues
|
|
9
|
+
Author: Supernatural
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: automation,browser,cdp,playwright,scraping,stealth
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Requires-Python: >=3.9
|
|
25
|
+
Requires-Dist: httpx>=0.25
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
30
|
+
Requires-Dist: respx>=0.20; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
32
|
+
Provides-Extra: playwright
|
|
33
|
+
Requires-Dist: playwright>=1.40; extra == 'playwright'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# Supernatural Python SDK
|
|
37
|
+
|
|
38
|
+
Official Python client for the [Supernatural](https://supernatural.sh) stealth browser-as-a-service API.
|
|
39
|
+
|
|
40
|
+
## Installation
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install sprntrl
|
|
44
|
+
# Optional: Playwright integration
|
|
45
|
+
pip install 'sprntrl[playwright]' && playwright install chromium
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Quick start
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from sprntrl import Sprntrl
|
|
52
|
+
|
|
53
|
+
client = Sprntrl() # reads SPRNTRL_API_KEY from env
|
|
54
|
+
|
|
55
|
+
session = client.sessions.create(os="macos", location="us-east")
|
|
56
|
+
|
|
57
|
+
# browser_session is a context manager that waits for the session,
|
|
58
|
+
# connects Playwright, and closes the browser + Playwright on exit.
|
|
59
|
+
# auto_whitelist=True registers your IP (CDP access is IP-gated).
|
|
60
|
+
with client.sessions.browser_session(session["id"], auto_whitelist=True) as browser:
|
|
61
|
+
page = browser.contexts[0].new_page()
|
|
62
|
+
page.goto("https://bot.sannysoft.com")
|
|
63
|
+
page.screenshot(path="out.png")
|
|
64
|
+
|
|
65
|
+
client.sessions.stop(session["id"])
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Async
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
import asyncio
|
|
72
|
+
from sprntrl import AsyncSprntrl
|
|
73
|
+
|
|
74
|
+
async def main():
|
|
75
|
+
async with AsyncSprntrl() as client:
|
|
76
|
+
session = await client.sessions.create(os="macos", location="us-east")
|
|
77
|
+
async with client.sessions.browser_session(session["id"], auto_whitelist=True) as browser:
|
|
78
|
+
page = await browser.contexts[0].new_page()
|
|
79
|
+
await page.goto("https://example.com")
|
|
80
|
+
await client.sessions.stop(session["id"])
|
|
81
|
+
|
|
82
|
+
asyncio.run(main())
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Lower-level `connect()` and `cdp_url()`
|
|
86
|
+
|
|
87
|
+
If you want to manage the browser lifecycle yourself:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
browser = client.sessions.connect(session_id, auto_whitelist=True)
|
|
91
|
+
# ... your code ...
|
|
92
|
+
browser.close()
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Or to hand the raw WebSocket URL to any CDP client (chrome-remote-interface-python, raw `websockets`, etc.):
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
url = client.sessions.cdp_url(session_id)
|
|
99
|
+
# url = "wss://api.supernatural.sh/api/v1/sessions/<id>/cdp"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Configuration
|
|
103
|
+
|
|
104
|
+
| Env var | Default |
|
|
105
|
+
|--------------------|----------------------------|
|
|
106
|
+
| `SPRNTRL_API_KEY` | — |
|
|
107
|
+
| `SPRNTRL_BASE_URL` | `https://api.supernatural.sh` |
|
|
108
|
+
|
|
109
|
+
Or override per client:
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
client = Sprntrl(api_key="sk_...", base_url="https://api.supernatural.sh", timeout=30, max_retries=2)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
## Resources
|
|
116
|
+
|
|
117
|
+
- `client.sessions` — create, list, list_active, list_history, list_resumable, list_locations, get, stop, resume, delete_persistent, wait_until_ready, connect, browser_session, cdp_url
|
|
118
|
+
- `client.sessions.files` — list, download, upload
|
|
119
|
+
- `client.profiles` — create, list, get, update, duplicate, delete
|
|
120
|
+
- `client.templates.list()`
|
|
121
|
+
- `client.ip_whitelist` — list, add, remove
|
|
122
|
+
- `client.usage` — current, history
|
|
123
|
+
- `client.user` — me, update, update_settings, change_password
|
|
124
|
+
- `client.api_keys` — list, create (full key returned ONCE), revoke
|
|
125
|
+
|
|
126
|
+
## Error handling
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from sprntrl import Sprntrl, APIError, RateLimitError, AuthenticationError
|
|
130
|
+
|
|
131
|
+
client = Sprntrl()
|
|
132
|
+
try:
|
|
133
|
+
client.sessions.create(os="macos", location="us-east")
|
|
134
|
+
except RateLimitError as e:
|
|
135
|
+
print("rate limited:", e.status, e.body)
|
|
136
|
+
except AuthenticationError:
|
|
137
|
+
print("bad API key")
|
|
138
|
+
except APIError as e:
|
|
139
|
+
print("api error:", e.status, e)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Transient errors (5xx, 429, 408, connection errors) are retried automatically up to `max_retries` times with exponential backoff.
|
|
143
|
+
|
|
144
|
+
## Gotchas
|
|
145
|
+
|
|
146
|
+
- **CDP access is IP-whitelist gated.** The WebSocket at `/api/v1/sessions/:id/cdp` does not accept bearer auth — instead, your public IP (as Cloudflare sees it) must be in your account's whitelist. Use `client.ip_whitelist.add("current")` or pass `auto_whitelist=True` to `sessions.connect`.
|
|
147
|
+
- **Sessions start async.** `sessions.create` returns immediately with `status: "creating"`. Call `sessions.wait_until_ready(id)` before connecting, or just use `sessions.connect()` which waits for you.
|
|
148
|
+
- **API key is shown only once.** `api_keys.create()` returns the full `key` field exactly once — store it immediately.
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
MIT
|
sprntrl-0.1.0/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# Supernatural Python SDK
|
|
2
|
+
|
|
3
|
+
Official Python client for the [Supernatural](https://supernatural.sh) stealth browser-as-a-service API.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install sprntrl
|
|
9
|
+
# Optional: Playwright integration
|
|
10
|
+
pip install 'sprntrl[playwright]' && playwright install chromium
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick start
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
from sprntrl import Sprntrl
|
|
17
|
+
|
|
18
|
+
client = Sprntrl() # reads SPRNTRL_API_KEY from env
|
|
19
|
+
|
|
20
|
+
session = client.sessions.create(os="macos", location="us-east")
|
|
21
|
+
|
|
22
|
+
# browser_session is a context manager that waits for the session,
|
|
23
|
+
# connects Playwright, and closes the browser + Playwright on exit.
|
|
24
|
+
# auto_whitelist=True registers your IP (CDP access is IP-gated).
|
|
25
|
+
with client.sessions.browser_session(session["id"], auto_whitelist=True) as browser:
|
|
26
|
+
page = browser.contexts[0].new_page()
|
|
27
|
+
page.goto("https://bot.sannysoft.com")
|
|
28
|
+
page.screenshot(path="out.png")
|
|
29
|
+
|
|
30
|
+
client.sessions.stop(session["id"])
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Async
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
import asyncio
|
|
37
|
+
from sprntrl import AsyncSprntrl
|
|
38
|
+
|
|
39
|
+
async def main():
|
|
40
|
+
async with AsyncSprntrl() as client:
|
|
41
|
+
session = await client.sessions.create(os="macos", location="us-east")
|
|
42
|
+
async with client.sessions.browser_session(session["id"], auto_whitelist=True) as browser:
|
|
43
|
+
page = await browser.contexts[0].new_page()
|
|
44
|
+
await page.goto("https://example.com")
|
|
45
|
+
await client.sessions.stop(session["id"])
|
|
46
|
+
|
|
47
|
+
asyncio.run(main())
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
### Lower-level `connect()` and `cdp_url()`
|
|
51
|
+
|
|
52
|
+
If you want to manage the browser lifecycle yourself:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
browser = client.sessions.connect(session_id, auto_whitelist=True)
|
|
56
|
+
# ... your code ...
|
|
57
|
+
browser.close()
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Or to hand the raw WebSocket URL to any CDP client (chrome-remote-interface-python, raw `websockets`, etc.):
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
url = client.sessions.cdp_url(session_id)
|
|
64
|
+
# url = "wss://api.supernatural.sh/api/v1/sessions/<id>/cdp"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Configuration
|
|
68
|
+
|
|
69
|
+
| Env var | Default |
|
|
70
|
+
|--------------------|----------------------------|
|
|
71
|
+
| `SPRNTRL_API_KEY` | — |
|
|
72
|
+
| `SPRNTRL_BASE_URL` | `https://api.supernatural.sh` |
|
|
73
|
+
|
|
74
|
+
Or override per client:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
client = Sprntrl(api_key="sk_...", base_url="https://api.supernatural.sh", timeout=30, max_retries=2)
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Resources
|
|
81
|
+
|
|
82
|
+
- `client.sessions` — create, list, list_active, list_history, list_resumable, list_locations, get, stop, resume, delete_persistent, wait_until_ready, connect, browser_session, cdp_url
|
|
83
|
+
- `client.sessions.files` — list, download, upload
|
|
84
|
+
- `client.profiles` — create, list, get, update, duplicate, delete
|
|
85
|
+
- `client.templates.list()`
|
|
86
|
+
- `client.ip_whitelist` — list, add, remove
|
|
87
|
+
- `client.usage` — current, history
|
|
88
|
+
- `client.user` — me, update, update_settings, change_password
|
|
89
|
+
- `client.api_keys` — list, create (full key returned ONCE), revoke
|
|
90
|
+
|
|
91
|
+
## Error handling
|
|
92
|
+
|
|
93
|
+
```python
|
|
94
|
+
from sprntrl import Sprntrl, APIError, RateLimitError, AuthenticationError
|
|
95
|
+
|
|
96
|
+
client = Sprntrl()
|
|
97
|
+
try:
|
|
98
|
+
client.sessions.create(os="macos", location="us-east")
|
|
99
|
+
except RateLimitError as e:
|
|
100
|
+
print("rate limited:", e.status, e.body)
|
|
101
|
+
except AuthenticationError:
|
|
102
|
+
print("bad API key")
|
|
103
|
+
except APIError as e:
|
|
104
|
+
print("api error:", e.status, e)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Transient errors (5xx, 429, 408, connection errors) are retried automatically up to `max_retries` times with exponential backoff.
|
|
108
|
+
|
|
109
|
+
## Gotchas
|
|
110
|
+
|
|
111
|
+
- **CDP access is IP-whitelist gated.** The WebSocket at `/api/v1/sessions/:id/cdp` does not accept bearer auth — instead, your public IP (as Cloudflare sees it) must be in your account's whitelist. Use `client.ip_whitelist.add("current")` or pass `auto_whitelist=True` to `sessions.connect`.
|
|
112
|
+
- **Sessions start async.** `sessions.create` returns immediately with `status: "creating"`. Call `sessions.wait_until_ready(id)` before connecting, or just use `sessions.connect()` which waits for you.
|
|
113
|
+
- **API key is shown only once.** `api_keys.create()` returns the full `key` field exactly once — store it immediately.
|
|
114
|
+
|
|
115
|
+
## License
|
|
116
|
+
|
|
117
|
+
MIT
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sprntrl"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Supernatural Python SDK — stealth browser-as-a-service client."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Supernatural" }]
|
|
13
|
+
keywords = ["browser", "automation", "playwright", "cdp", "stealth", "scraping"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
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
|
+
"Topic :: Internet :: WWW/HTTP :: Browsers",
|
|
25
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"httpx>=0.25",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[project.optional-dependencies]
|
|
32
|
+
playwright = ["playwright>=1.40"]
|
|
33
|
+
dev = [
|
|
34
|
+
"pytest>=7",
|
|
35
|
+
"pytest-asyncio>=0.23",
|
|
36
|
+
"respx>=0.20",
|
|
37
|
+
"ruff>=0.5",
|
|
38
|
+
"mypy>=1.8",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[project.urls]
|
|
42
|
+
Homepage = "https://supernatural.sh"
|
|
43
|
+
Repository = "https://github.com/supernatural-browser/sprntrl-python"
|
|
44
|
+
Documentation = "https://app.supernatural.sh/docs"
|
|
45
|
+
"Bug Tracker" = "https://github.com/supernatural-browser/sprntrl-python/issues"
|
|
46
|
+
|
|
47
|
+
[tool.hatch.build.targets.wheel]
|
|
48
|
+
packages = ["sprntrl"]
|
|
49
|
+
# sprntrl/py.typed (PEP 561) is under the package dir, so hatchling ships it
|
|
50
|
+
# in the wheel automatically alongside the .py sources.
|
|
51
|
+
|
|
52
|
+
[tool.hatch.build.targets.sdist]
|
|
53
|
+
# Default sdist is VCS-driven; list explicitly so LICENSE + py.typed ship even
|
|
54
|
+
# before they're committed and the sdist stays reproducible.
|
|
55
|
+
include = ["sprntrl", "README.md", "LICENSE", "pyproject.toml"]
|
|
56
|
+
|
|
57
|
+
[tool.ruff]
|
|
58
|
+
line-length = 100
|
|
59
|
+
target-version = "py39"
|
|
60
|
+
|
|
61
|
+
[tool.mypy]
|
|
62
|
+
python_version = "3.9"
|
|
63
|
+
strict = false
|
|
64
|
+
warn_unused_ignores = true
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Sprntrl Python SDK — stealth browser-as-a-service client."""
|
|
2
|
+
|
|
3
|
+
from ._client import Sprntrl, AsyncSprntrl
|
|
4
|
+
from ._errors import (
|
|
5
|
+
SprntrlError,
|
|
6
|
+
APIError,
|
|
7
|
+
BadRequestError,
|
|
8
|
+
AuthenticationError,
|
|
9
|
+
PermissionDeniedError,
|
|
10
|
+
NotFoundError,
|
|
11
|
+
ConflictError,
|
|
12
|
+
UnprocessableEntityError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
InternalServerError,
|
|
15
|
+
APIConnectionError,
|
|
16
|
+
APIConnectionTimeoutError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Sprntrl",
|
|
23
|
+
"AsyncSprntrl",
|
|
24
|
+
"SprntrlError",
|
|
25
|
+
"APIError",
|
|
26
|
+
"BadRequestError",
|
|
27
|
+
"AuthenticationError",
|
|
28
|
+
"PermissionDeniedError",
|
|
29
|
+
"NotFoundError",
|
|
30
|
+
"ConflictError",
|
|
31
|
+
"UnprocessableEntityError",
|
|
32
|
+
"RateLimitError",
|
|
33
|
+
"InternalServerError",
|
|
34
|
+
"APIConnectionError",
|
|
35
|
+
"APIConnectionTimeoutError",
|
|
36
|
+
"__version__",
|
|
37
|
+
]
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
import asyncio
|
|
6
|
+
import random
|
|
7
|
+
from typing import Any, Mapping, Union
|
|
8
|
+
from urllib.parse import urljoin
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from ._errors import (
|
|
13
|
+
APIConnectionError,
|
|
14
|
+
APIConnectionTimeoutError,
|
|
15
|
+
error_for_status,
|
|
16
|
+
SprntrlError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
DEFAULT_BASE_URL = "https://api.supernatural.sh"
|
|
21
|
+
DEFAULT_TIMEOUT = 60.0
|
|
22
|
+
DEFAULT_MAX_RETRIES = 2
|
|
23
|
+
_USER_AGENT = "sprntrl-python/0.1.0"
|
|
24
|
+
|
|
25
|
+
JSONLike = Union[Mapping[str, Any], list, str, int, float, bool, None]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _BaseClient:
|
|
29
|
+
"""Shared config and helpers for sync and async clients."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
api_key: str | None = None,
|
|
35
|
+
base_url: str | None = None,
|
|
36
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
37
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
38
|
+
default_headers: Mapping[str, str] | None = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
api_key = api_key or os.environ.get("SPRNTRL_API_KEY")
|
|
41
|
+
if not api_key:
|
|
42
|
+
raise SprntrlError(
|
|
43
|
+
"No API key provided. Pass api_key= or set SPRNTRL_API_KEY."
|
|
44
|
+
)
|
|
45
|
+
base_url = base_url or os.environ.get("SPRNTRL_BASE_URL") or DEFAULT_BASE_URL
|
|
46
|
+
self.api_key = api_key
|
|
47
|
+
self.base_url = base_url.rstrip("/")
|
|
48
|
+
self.timeout = timeout
|
|
49
|
+
self.max_retries = max_retries
|
|
50
|
+
self._default_headers = dict(default_headers or {})
|
|
51
|
+
|
|
52
|
+
def _headers(self, extra: Mapping[str, str] | None = None) -> dict[str, str]:
|
|
53
|
+
h = {
|
|
54
|
+
"Authorization": f"ApiKey {self.api_key}",
|
|
55
|
+
"Accept": "application/json",
|
|
56
|
+
"User-Agent": _USER_AGENT,
|
|
57
|
+
**self._default_headers,
|
|
58
|
+
}
|
|
59
|
+
if extra:
|
|
60
|
+
h.update(extra)
|
|
61
|
+
return h
|
|
62
|
+
|
|
63
|
+
def _url(self, path: str) -> str:
|
|
64
|
+
if path.startswith("http://") or path.startswith("https://"):
|
|
65
|
+
return path
|
|
66
|
+
if not path.startswith("/"):
|
|
67
|
+
path = "/" + path
|
|
68
|
+
return self.base_url + path
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def _should_retry(exc: Exception | None, status: int | None) -> bool:
|
|
72
|
+
if exc is not None:
|
|
73
|
+
return True # connection-level errors
|
|
74
|
+
if status is None:
|
|
75
|
+
return False
|
|
76
|
+
return status == 408 or status == 409 or status == 429 or status >= 500
|
|
77
|
+
|
|
78
|
+
@staticmethod
|
|
79
|
+
def _backoff(attempt: int) -> float:
|
|
80
|
+
# 0.5s, 1s, 2s + jitter
|
|
81
|
+
base = 0.5 * (2 ** attempt)
|
|
82
|
+
return base + random.uniform(0, 0.25)
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def _parse_error(response: httpx.Response) -> tuple[str, Any]:
|
|
86
|
+
body: Any = None
|
|
87
|
+
try:
|
|
88
|
+
body = response.json()
|
|
89
|
+
except Exception:
|
|
90
|
+
body = response.text
|
|
91
|
+
msg = None
|
|
92
|
+
if isinstance(body, dict):
|
|
93
|
+
msg = body.get("error") or body.get("message") or body.get("detail")
|
|
94
|
+
if not msg:
|
|
95
|
+
msg = f"HTTP {response.status_code}"
|
|
96
|
+
return msg, body
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class SyncClient(_BaseClient):
|
|
100
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
101
|
+
super().__init__(**kwargs)
|
|
102
|
+
self._http = httpx.Client(timeout=self.timeout)
|
|
103
|
+
|
|
104
|
+
def close(self) -> None:
|
|
105
|
+
self._http.close()
|
|
106
|
+
|
|
107
|
+
def __enter__(self) -> "SyncClient":
|
|
108
|
+
return self
|
|
109
|
+
|
|
110
|
+
def __exit__(self, *exc: Any) -> None:
|
|
111
|
+
self.close()
|
|
112
|
+
|
|
113
|
+
def _request(
|
|
114
|
+
self,
|
|
115
|
+
method: str,
|
|
116
|
+
path: str,
|
|
117
|
+
*,
|
|
118
|
+
json: JSONLike = None,
|
|
119
|
+
params: Mapping[str, Any] | None = None,
|
|
120
|
+
headers: Mapping[str, str] | None = None,
|
|
121
|
+
files: Any = None,
|
|
122
|
+
data: Any = None,
|
|
123
|
+
stream: bool = False,
|
|
124
|
+
) -> Any:
|
|
125
|
+
url = self._url(path)
|
|
126
|
+
h = self._headers(headers)
|
|
127
|
+
last_exc: Exception | None = None
|
|
128
|
+
for attempt in range(self.max_retries + 1):
|
|
129
|
+
try:
|
|
130
|
+
response = self._http.request(
|
|
131
|
+
method,
|
|
132
|
+
url,
|
|
133
|
+
json=json if files is None and data is None else None,
|
|
134
|
+
params=params,
|
|
135
|
+
headers=h,
|
|
136
|
+
files=files,
|
|
137
|
+
data=data,
|
|
138
|
+
)
|
|
139
|
+
except httpx.TimeoutException as exc:
|
|
140
|
+
last_exc = APIConnectionTimeoutError(str(exc))
|
|
141
|
+
except httpx.RequestError as exc:
|
|
142
|
+
last_exc = APIConnectionError(str(exc), cause=exc)
|
|
143
|
+
else:
|
|
144
|
+
status = response.status_code
|
|
145
|
+
if 200 <= status < 300:
|
|
146
|
+
if stream:
|
|
147
|
+
return response
|
|
148
|
+
if not response.content:
|
|
149
|
+
return None
|
|
150
|
+
ctype = response.headers.get("content-type", "")
|
|
151
|
+
if "application/json" in ctype:
|
|
152
|
+
return response.json()
|
|
153
|
+
return response.content
|
|
154
|
+
if self._should_retry(None, status) and attempt < self.max_retries:
|
|
155
|
+
time.sleep(self._backoff(attempt))
|
|
156
|
+
continue
|
|
157
|
+
msg, body = self._parse_error(response)
|
|
158
|
+
raise error_for_status(status, msg, body=body)
|
|
159
|
+
if attempt < self.max_retries:
|
|
160
|
+
time.sleep(self._backoff(attempt))
|
|
161
|
+
continue
|
|
162
|
+
raise last_exc
|
|
163
|
+
assert last_exc is not None
|
|
164
|
+
raise last_exc
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class AsyncClient(_BaseClient):
|
|
168
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
169
|
+
super().__init__(**kwargs)
|
|
170
|
+
self._http = httpx.AsyncClient(timeout=self.timeout)
|
|
171
|
+
|
|
172
|
+
async def close(self) -> None:
|
|
173
|
+
await self._http.aclose()
|
|
174
|
+
|
|
175
|
+
async def __aenter__(self) -> "AsyncClient":
|
|
176
|
+
return self
|
|
177
|
+
|
|
178
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
179
|
+
await self.close()
|
|
180
|
+
|
|
181
|
+
async def _request(
|
|
182
|
+
self,
|
|
183
|
+
method: str,
|
|
184
|
+
path: str,
|
|
185
|
+
*,
|
|
186
|
+
json: JSONLike = None,
|
|
187
|
+
params: Mapping[str, Any] | None = None,
|
|
188
|
+
headers: Mapping[str, str] | None = None,
|
|
189
|
+
files: Any = None,
|
|
190
|
+
data: Any = None,
|
|
191
|
+
stream: bool = False,
|
|
192
|
+
) -> Any:
|
|
193
|
+
url = self._url(path)
|
|
194
|
+
h = self._headers(headers)
|
|
195
|
+
last_exc: Exception | None = None
|
|
196
|
+
for attempt in range(self.max_retries + 1):
|
|
197
|
+
try:
|
|
198
|
+
response = await self._http.request(
|
|
199
|
+
method,
|
|
200
|
+
url,
|
|
201
|
+
json=json if files is None and data is None else None,
|
|
202
|
+
params=params,
|
|
203
|
+
headers=h,
|
|
204
|
+
files=files,
|
|
205
|
+
data=data,
|
|
206
|
+
)
|
|
207
|
+
except httpx.TimeoutException as exc:
|
|
208
|
+
last_exc = APIConnectionTimeoutError(str(exc))
|
|
209
|
+
except httpx.RequestError as exc:
|
|
210
|
+
last_exc = APIConnectionError(str(exc), cause=exc)
|
|
211
|
+
else:
|
|
212
|
+
status = response.status_code
|
|
213
|
+
if 200 <= status < 300:
|
|
214
|
+
if stream:
|
|
215
|
+
return response
|
|
216
|
+
if not response.content:
|
|
217
|
+
return None
|
|
218
|
+
ctype = response.headers.get("content-type", "")
|
|
219
|
+
if "application/json" in ctype:
|
|
220
|
+
return response.json()
|
|
221
|
+
return response.content
|
|
222
|
+
if self._should_retry(None, status) and attempt < self.max_retries:
|
|
223
|
+
await asyncio.sleep(self._backoff(attempt))
|
|
224
|
+
continue
|
|
225
|
+
msg, body = self._parse_error(response)
|
|
226
|
+
raise error_for_status(status, msg, body=body)
|
|
227
|
+
if attempt < self.max_retries:
|
|
228
|
+
await asyncio.sleep(self._backoff(attempt))
|
|
229
|
+
continue
|
|
230
|
+
raise last_exc
|
|
231
|
+
assert last_exc is not None
|
|
232
|
+
raise last_exc
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from ._base_client import SyncClient, AsyncClient
|
|
4
|
+
from .resources import (
|
|
5
|
+
Sessions,
|
|
6
|
+
AsyncSessions,
|
|
7
|
+
Profiles,
|
|
8
|
+
AsyncProfiles,
|
|
9
|
+
Templates,
|
|
10
|
+
AsyncTemplates,
|
|
11
|
+
IPWhitelist,
|
|
12
|
+
AsyncIPWhitelist,
|
|
13
|
+
Usage,
|
|
14
|
+
AsyncUsage,
|
|
15
|
+
User,
|
|
16
|
+
AsyncUser,
|
|
17
|
+
APIKeys,
|
|
18
|
+
AsyncAPIKeys,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Sprntrl(SyncClient):
|
|
23
|
+
"""Sync Sprntrl SDK client.
|
|
24
|
+
|
|
25
|
+
Usage:
|
|
26
|
+
with Sprntrl() as client:
|
|
27
|
+
session = client.sessions.create(os="macos", location="us-east")
|
|
28
|
+
client.sessions.wait_until_ready(session["id"])
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, **kwargs) -> None:
|
|
32
|
+
super().__init__(**kwargs)
|
|
33
|
+
self.sessions = Sessions(self)
|
|
34
|
+
self.profiles = Profiles(self)
|
|
35
|
+
self.templates = Templates(self)
|
|
36
|
+
self.ip_whitelist = IPWhitelist(self)
|
|
37
|
+
self.usage = Usage(self)
|
|
38
|
+
self.user = User(self)
|
|
39
|
+
self.api_keys = APIKeys(self)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AsyncSprntrl(AsyncClient):
|
|
43
|
+
"""Async Sprntrl SDK client.
|
|
44
|
+
|
|
45
|
+
Usage:
|
|
46
|
+
async with AsyncSprntrl() as client:
|
|
47
|
+
session = await client.sessions.create(os="macos", location="us-east")
|
|
48
|
+
await client.sessions.wait_until_ready(session["id"])
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self, **kwargs) -> None:
|
|
52
|
+
super().__init__(**kwargs)
|
|
53
|
+
self.sessions = AsyncSessions(self)
|
|
54
|
+
self.profiles = AsyncProfiles(self)
|
|
55
|
+
self.templates = AsyncTemplates(self)
|
|
56
|
+
self.ip_whitelist = AsyncIPWhitelist(self)
|
|
57
|
+
self.usage = AsyncUsage(self)
|
|
58
|
+
self.user = AsyncUser(self)
|
|
59
|
+
self.api_keys = AsyncAPIKeys(self)
|