sprntrl 0.1.0__py3-none-any.whl
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/__init__.py +37 -0
- sprntrl/_base_client.py +232 -0
- sprntrl/_client.py +59 -0
- sprntrl/_errors.py +65 -0
- sprntrl/_types.py +161 -0
- sprntrl/_utils.py +16 -0
- sprntrl/lib/__init__.py +0 -0
- sprntrl/lib/browser.py +105 -0
- sprntrl/py.typed +0 -0
- sprntrl/resources/__init__.py +24 -0
- sprntrl/resources/api_keys.py +43 -0
- sprntrl/resources/files.py +89 -0
- sprntrl/resources/ip_whitelist.py +56 -0
- sprntrl/resources/profiles.py +95 -0
- sprntrl/resources/sessions.py +419 -0
- sprntrl/resources/templates.py +26 -0
- sprntrl/resources/usage.py +32 -0
- sprntrl/resources/user.py +70 -0
- sprntrl-0.1.0.dist-info/METADATA +152 -0
- sprntrl-0.1.0.dist-info/RECORD +22 -0
- sprntrl-0.1.0.dist-info/WHEEL +4 -0
- sprntrl-0.1.0.dist-info/licenses/LICENSE +21 -0
sprntrl/__init__.py
ADDED
|
@@ -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
|
+
]
|
sprntrl/_base_client.py
ADDED
|
@@ -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
|
sprntrl/_client.py
ADDED
|
@@ -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)
|
sprntrl/_errors.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SprntrlError(Exception):
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class APIError(SprntrlError):
|
|
11
|
+
def __init__(
|
|
12
|
+
self,
|
|
13
|
+
message: str,
|
|
14
|
+
*,
|
|
15
|
+
status: int | None = None,
|
|
16
|
+
body: Any = None,
|
|
17
|
+
) -> None:
|
|
18
|
+
super().__init__(message)
|
|
19
|
+
self.status = status
|
|
20
|
+
# NOTE: the live httpx.Response is deliberately NOT retained. Its
|
|
21
|
+
# `.request.headers` carries `Authorization: ApiKey <key>`, so keeping
|
|
22
|
+
# it here would leak the API key into any log/Sentry capture of the
|
|
23
|
+
# exception. Only the status code and the parsed (server-controlled)
|
|
24
|
+
# error body are exposed.
|
|
25
|
+
self.body = body
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class BadRequestError(APIError): pass
|
|
29
|
+
class AuthenticationError(APIError): pass
|
|
30
|
+
class PermissionDeniedError(APIError): pass
|
|
31
|
+
class NotFoundError(APIError): pass
|
|
32
|
+
class ConflictError(APIError): pass
|
|
33
|
+
class UnprocessableEntityError(APIError): pass
|
|
34
|
+
class RateLimitError(APIError): pass
|
|
35
|
+
class InternalServerError(APIError): pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class APIConnectionError(APIError):
|
|
39
|
+
def __init__(self, message: str = "Connection error", *, cause: BaseException | None = None) -> None:
|
|
40
|
+
super().__init__(message)
|
|
41
|
+
self.__cause__ = cause
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class APIConnectionTimeoutError(APIConnectionError):
|
|
45
|
+
def __init__(self, message: str = "Request timed out") -> None:
|
|
46
|
+
super().__init__(message)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_STATUS_MAP: dict[int, type[APIError]] = {
|
|
50
|
+
400: BadRequestError,
|
|
51
|
+
401: AuthenticationError,
|
|
52
|
+
403: PermissionDeniedError,
|
|
53
|
+
404: NotFoundError,
|
|
54
|
+
409: ConflictError,
|
|
55
|
+
422: UnprocessableEntityError,
|
|
56
|
+
429: RateLimitError,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def error_for_status(status: int, message: str, *, body: Any = None) -> APIError:
|
|
61
|
+
if status >= 500:
|
|
62
|
+
cls = InternalServerError
|
|
63
|
+
else:
|
|
64
|
+
cls = _STATUS_MAP.get(status, APIError)
|
|
65
|
+
return cls(message, status=status, body=body)
|
sprntrl/_types.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal, TypedDict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
ProxyProtocol = Literal["HTTP", "HTTPS", "SOCKS5"]
|
|
7
|
+
SessionStatus = Literal["creating", "running", "stopping", "stopped", "failed", "archiving"]
|
|
8
|
+
OS = Literal["macos", "windows"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ProxyConfig(TypedDict, total=False):
|
|
12
|
+
protocol: ProxyProtocol
|
|
13
|
+
host: str
|
|
14
|
+
port: int
|
|
15
|
+
username: str
|
|
16
|
+
password: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ProxySummary(TypedDict, total=False):
|
|
20
|
+
"""Display-friendly view of a session's proxy. Present only for BYO
|
|
21
|
+
(bring-your-own) proxies — pool proxies are shared infra and the API
|
|
22
|
+
deliberately hides their host/port. The password is never returned."""
|
|
23
|
+
|
|
24
|
+
protocol: str
|
|
25
|
+
host: str
|
|
26
|
+
port: int
|
|
27
|
+
username: str
|
|
28
|
+
address: str # convenience form: protocol://host:port
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Session(TypedDict, total=False):
|
|
32
|
+
id: str
|
|
33
|
+
user_id: str
|
|
34
|
+
profile_id: str | None
|
|
35
|
+
container_id: str | None
|
|
36
|
+
chrome_port: int | None
|
|
37
|
+
status: SessionStatus
|
|
38
|
+
persistent: bool
|
|
39
|
+
captcha_solver: bool
|
|
40
|
+
session_name: str | None
|
|
41
|
+
data_dir_path: str | None
|
|
42
|
+
data_dir_size: int
|
|
43
|
+
storage_status: str
|
|
44
|
+
os: OS
|
|
45
|
+
location: str
|
|
46
|
+
started_at: str | None
|
|
47
|
+
stopped_at: str | None
|
|
48
|
+
created_at: str
|
|
49
|
+
cdp_url: str
|
|
50
|
+
uptime_seconds: int
|
|
51
|
+
sidecar_port: int
|
|
52
|
+
proxy: ProxySummary # present only for BYO-proxy sessions
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class PaginatedSessions(TypedDict):
|
|
56
|
+
sessions: list[Session]
|
|
57
|
+
total: int
|
|
58
|
+
page: int
|
|
59
|
+
per_page: int
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Profile(TypedDict, total=False):
|
|
63
|
+
id: str
|
|
64
|
+
user_id: str
|
|
65
|
+
name: str
|
|
66
|
+
description: str | None
|
|
67
|
+
os: str | None
|
|
68
|
+
location: str | None
|
|
69
|
+
persona: str | None
|
|
70
|
+
config_json: Any
|
|
71
|
+
template_id: str | None
|
|
72
|
+
created_at: str
|
|
73
|
+
updated_at: str
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Template(TypedDict, total=False):
|
|
77
|
+
id: str
|
|
78
|
+
name: str
|
|
79
|
+
os: str
|
|
80
|
+
config: Any
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class IPWhitelistEntry(TypedDict, total=False):
|
|
84
|
+
id: str
|
|
85
|
+
user_id: str
|
|
86
|
+
ip_address: str
|
|
87
|
+
label: str | None
|
|
88
|
+
created_at: str
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class APIKey(TypedDict, total=False):
|
|
92
|
+
id: str
|
|
93
|
+
name: str
|
|
94
|
+
key_prefix: str
|
|
95
|
+
last_used_at: str | None
|
|
96
|
+
created_at: str
|
|
97
|
+
revoked_at: str | None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class APIKeyCreated(APIKey, total=False):
|
|
101
|
+
key: str # Only present once at creation time
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class Usage(TypedDict, total=False):
|
|
105
|
+
total_minutes: int
|
|
106
|
+
plan_minutes: int
|
|
107
|
+
overage_minutes: int
|
|
108
|
+
month: str
|
|
109
|
+
plan: str
|
|
110
|
+
usage_percentage: float
|
|
111
|
+
allow_hours_overage: bool
|
|
112
|
+
profile_count: int
|
|
113
|
+
max_profiles: int
|
|
114
|
+
bandwidth_rate_cents: int # pool-proxy bandwidth, cents per GB
|
|
115
|
+
hours_overage_rate_cents: int # cents per hour over plan minutes
|
|
116
|
+
bandwidth_bytes: int # pool-proxy bytes consumed this period
|
|
117
|
+
bandwidth_charge_amount_cents: int # accrued bandwidth overage, cents
|
|
118
|
+
hours_overage_minutes: int # minutes used beyond plan allowance
|
|
119
|
+
hours_overage_amount_cents: int # accrued hours-overage charge, cents
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class UsageMonth(TypedDict):
|
|
123
|
+
month: str
|
|
124
|
+
total_minutes: int
|
|
125
|
+
plan_minutes: int
|
|
126
|
+
overage_minutes: int
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
AccountStatus = Literal["pending_verification", "pending_payment", "active"]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class User(TypedDict, total=False):
|
|
133
|
+
id: str
|
|
134
|
+
email: str
|
|
135
|
+
name: str | None
|
|
136
|
+
plan: str
|
|
137
|
+
role: str
|
|
138
|
+
allow_hours_overage: bool
|
|
139
|
+
byo_proxy_only: bool # account may only use BYO proxies (no pool proxy)
|
|
140
|
+
bandwidth_rate_cents: int # pool-proxy bandwidth, cents per GB
|
|
141
|
+
hours_overage_rate_cents: int # cents per hour over plan minutes
|
|
142
|
+
must_change_password: bool
|
|
143
|
+
email_verified: bool
|
|
144
|
+
account_status: AccountStatus
|
|
145
|
+
oauth_provider: str # "google" | "github"; absent for password accounts
|
|
146
|
+
created_at: str
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class ChangePasswordResult(TypedDict, total=False):
|
|
150
|
+
message: str
|
|
151
|
+
# Fresh tokens — issued so a programmatic client can swap credentials
|
|
152
|
+
# without a re-login. Absent for cookie/session-only flows.
|
|
153
|
+
access_token: str
|
|
154
|
+
refresh_token: str
|
|
155
|
+
token_type: str
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class FileInfo(TypedDict, total=False):
|
|
159
|
+
name: str
|
|
160
|
+
size: int
|
|
161
|
+
modified: str
|
sprntrl/_utils.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from urllib.parse import quote
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def seg(value: object) -> str:
|
|
7
|
+
"""Percent-encode a single URL path segment.
|
|
8
|
+
|
|
9
|
+
Caller- or server-supplied values (session ids, filenames, profile ids,
|
|
10
|
+
etc.) are interpolated into request paths. Without encoding, a value
|
|
11
|
+
containing ``/``, ``..``, ``?`` or ``#`` could traverse to a different
|
|
12
|
+
authenticated endpoint or inject a query/fragment — all with the API key
|
|
13
|
+
attached. ``safe=""`` ensures even ``/`` is escaped so the value can only
|
|
14
|
+
ever be a single segment. Mirrors the Node SDK's ``encodeURIComponent``.
|
|
15
|
+
"""
|
|
16
|
+
return quote(str(value), safe="")
|
sprntrl/lib/__init__.py
ADDED
|
File without changes
|
sprntrl/lib/browser.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any, Mapping
|
|
4
|
+
from urllib.parse import urlparse, urlunparse
|
|
5
|
+
|
|
6
|
+
from .._errors import SprntrlError
|
|
7
|
+
from .._utils import seg
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .._base_client import SyncClient, AsyncClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _cdp_url_for(client: "SyncClient | AsyncClient", session: Mapping[str, Any]) -> str:
|
|
14
|
+
"""Build the CDP WebSocket URL from the client's base URL. The API proxies
|
|
15
|
+
the WebSocket through /api/v1/sessions/:id/cdp with an IP-whitelist check —
|
|
16
|
+
this path is what external callers must use. (The server also returns a
|
|
17
|
+
cdp_url field, but that's an internal host:port that isn't reachable from
|
|
18
|
+
outside the API host.)"""
|
|
19
|
+
session_id = session["id"]
|
|
20
|
+
parsed = urlparse(client.base_url)
|
|
21
|
+
scheme = "wss" if parsed.scheme == "https" else "ws"
|
|
22
|
+
return urlunparse((
|
|
23
|
+
scheme, parsed.netloc, f"/api/v1/sessions/{seg(session_id)}/cdp", "", "", "",
|
|
24
|
+
))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _ensure_playwright() -> Any:
|
|
28
|
+
try:
|
|
29
|
+
from playwright.sync_api import sync_playwright # type: ignore
|
|
30
|
+
except ImportError as exc:
|
|
31
|
+
raise SprntrlError(
|
|
32
|
+
"Playwright is not installed. Run `pip install playwright` "
|
|
33
|
+
"(and `playwright install chromium` once)."
|
|
34
|
+
) from exc
|
|
35
|
+
return sync_playwright
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _ensure_playwright_async() -> Any:
|
|
39
|
+
try:
|
|
40
|
+
from playwright.async_api import async_playwright # type: ignore
|
|
41
|
+
except ImportError as exc:
|
|
42
|
+
raise SprntrlError(
|
|
43
|
+
"Playwright is not installed. Run `pip install playwright` "
|
|
44
|
+
"(and `playwright install chromium` once)."
|
|
45
|
+
) from exc
|
|
46
|
+
return async_playwright
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def connect_sync(
|
|
50
|
+
client: "SyncClient",
|
|
51
|
+
session: Mapping[str, Any],
|
|
52
|
+
*,
|
|
53
|
+
framework: str = "playwright",
|
|
54
|
+
auto_whitelist: bool = False,
|
|
55
|
+
) -> Any:
|
|
56
|
+
if framework != "playwright":
|
|
57
|
+
raise SprntrlError(
|
|
58
|
+
f"Unsupported framework {framework!r}. Only 'playwright' is supported in Python."
|
|
59
|
+
)
|
|
60
|
+
if auto_whitelist:
|
|
61
|
+
try:
|
|
62
|
+
client._request(
|
|
63
|
+
"POST", "/api/v1/settings/ip-whitelist", json={"ip": "current"}
|
|
64
|
+
)
|
|
65
|
+
except Exception:
|
|
66
|
+
# Already-whitelisted or race; ignore — connect attempt will surface the real problem.
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
cdp_url = _cdp_url_for(client, session)
|
|
70
|
+
sync_playwright = _ensure_playwright()
|
|
71
|
+
pw = sync_playwright().start()
|
|
72
|
+
try:
|
|
73
|
+
return pw.chromium.connect_over_cdp(cdp_url)
|
|
74
|
+
except Exception:
|
|
75
|
+
pw.stop()
|
|
76
|
+
raise
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def connect_async(
|
|
80
|
+
client: "AsyncClient",
|
|
81
|
+
session: Mapping[str, Any],
|
|
82
|
+
*,
|
|
83
|
+
framework: str = "playwright",
|
|
84
|
+
auto_whitelist: bool = False,
|
|
85
|
+
) -> Any:
|
|
86
|
+
if framework != "playwright":
|
|
87
|
+
raise SprntrlError(
|
|
88
|
+
f"Unsupported framework {framework!r}. Only 'playwright' is supported in Python."
|
|
89
|
+
)
|
|
90
|
+
if auto_whitelist:
|
|
91
|
+
try:
|
|
92
|
+
await client._request(
|
|
93
|
+
"POST", "/api/v1/settings/ip-whitelist", json={"ip": "current"}
|
|
94
|
+
)
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
cdp_url = _cdp_url_for(client, session)
|
|
99
|
+
async_playwright = _ensure_playwright_async()
|
|
100
|
+
pw = await async_playwright().start()
|
|
101
|
+
try:
|
|
102
|
+
return await pw.chromium.connect_over_cdp(cdp_url)
|
|
103
|
+
except Exception:
|
|
104
|
+
await pw.stop()
|
|
105
|
+
raise
|
sprntrl/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from .sessions import Sessions, AsyncSessions
|
|
2
|
+
from .profiles import Profiles, AsyncProfiles
|
|
3
|
+
from .templates import Templates, AsyncTemplates
|
|
4
|
+
from .ip_whitelist import IPWhitelist, AsyncIPWhitelist
|
|
5
|
+
from .usage import Usage, AsyncUsage
|
|
6
|
+
from .user import User, AsyncUser
|
|
7
|
+
from .api_keys import APIKeys, AsyncAPIKeys
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Sessions",
|
|
11
|
+
"AsyncSessions",
|
|
12
|
+
"Profiles",
|
|
13
|
+
"AsyncProfiles",
|
|
14
|
+
"Templates",
|
|
15
|
+
"AsyncTemplates",
|
|
16
|
+
"IPWhitelist",
|
|
17
|
+
"AsyncIPWhitelist",
|
|
18
|
+
"Usage",
|
|
19
|
+
"AsyncUsage",
|
|
20
|
+
"User",
|
|
21
|
+
"AsyncUser",
|
|
22
|
+
"APIKeys",
|
|
23
|
+
"AsyncAPIKeys",
|
|
24
|
+
]
|