scalebrowser 0.2.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.
- scalebrowser/__init__.py +234 -0
- scalebrowser/_http.py +180 -0
- scalebrowser/_sync.py +604 -0
- scalebrowser/_version.py +3 -0
- scalebrowser/cdp.py +429 -0
- scalebrowser/client.py +825 -0
- scalebrowser/errors.py +97 -0
- scalebrowser/events.py +52 -0
- scalebrowser/models.py +861 -0
- scalebrowser/models_control.py +128 -0
- scalebrowser/models_identity.py +130 -0
- scalebrowser/models_runs.py +56 -0
- scalebrowser-0.2.0.dist-info/METADATA +170 -0
- scalebrowser-0.2.0.dist-info/RECORD +16 -0
- scalebrowser-0.2.0.dist-info/WHEEL +4 -0
- scalebrowser-0.2.0.dist-info/licenses/LICENSE +21 -0
scalebrowser/__init__.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Scalebrowser — official Python SDK.
|
|
2
|
+
|
|
3
|
+
Typed REST client + a direct-CDP driver (nodriver-style, **not** Playwright) for
|
|
4
|
+
the self-hosted Scalebrowser daemon.
|
|
5
|
+
|
|
6
|
+
from scalebrowser import ScalebrowserClient # synchronous
|
|
7
|
+
from scalebrowser import AsyncScalebrowserClient # asyncio
|
|
8
|
+
|
|
9
|
+
sb = ScalebrowserClient(base_url="http://127.0.0.1:8787", token="…")
|
|
10
|
+
with sb.launch(profile_id, headless=True) as page: # start → CDP → stop
|
|
11
|
+
page.navigate("https://example.com")
|
|
12
|
+
page.humanize_click(120, 240)
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from ._sync import ScalebrowserClient, SyncCdpSession
|
|
18
|
+
from ._version import __version__
|
|
19
|
+
from .cdp import CdpSession, connect_cdp
|
|
20
|
+
from .client import (
|
|
21
|
+
DEFAULT_BASE_URL,
|
|
22
|
+
DEFAULT_CAPACITY_MAX_CONCURRENT,
|
|
23
|
+
DEFAULT_CAPACITY_RAM_BUDGET_MB,
|
|
24
|
+
AsyncScalebrowserClient,
|
|
25
|
+
)
|
|
26
|
+
from .errors import ApiError, CdpError, ErrorCode, NetworkError, ScalebrowserError
|
|
27
|
+
from .events import parse_sse_frame
|
|
28
|
+
from .models_control import (
|
|
29
|
+
Account,
|
|
30
|
+
ArtifactBytes,
|
|
31
|
+
ArtifactPutResult,
|
|
32
|
+
EffectiveInterruptionLock,
|
|
33
|
+
HealthStatus,
|
|
34
|
+
InterruptionAuthor,
|
|
35
|
+
InterruptionChoice,
|
|
36
|
+
InterruptionLock,
|
|
37
|
+
InterruptionLockRow,
|
|
38
|
+
InterruptionLockView,
|
|
39
|
+
InterruptionRuleRow,
|
|
40
|
+
ReadyStatus,
|
|
41
|
+
SetInterruptionLockBody,
|
|
42
|
+
SetInterruptionRuleBody,
|
|
43
|
+
)
|
|
44
|
+
from .models_identity import (
|
|
45
|
+
BindInboxBody,
|
|
46
|
+
Inbox,
|
|
47
|
+
InboxBinding,
|
|
48
|
+
InboxBindings,
|
|
49
|
+
InboxChannel,
|
|
50
|
+
PasskeyRetiredReason,
|
|
51
|
+
PasskeyRow,
|
|
52
|
+
PutInboxBody,
|
|
53
|
+
RevealCookiesBody,
|
|
54
|
+
RevealCookiesResult,
|
|
55
|
+
RevealedCookie,
|
|
56
|
+
)
|
|
57
|
+
from .models_runs import ActivitySnapshot, AgentRun, GoalSource, OutcomeSource
|
|
58
|
+
from .models import (
|
|
59
|
+
AudioProps,
|
|
60
|
+
Brand,
|
|
61
|
+
BulkAssignProxyBody,
|
|
62
|
+
BulkCreateBody,
|
|
63
|
+
BulkIdsBody,
|
|
64
|
+
CheckProxyConfigBody,
|
|
65
|
+
ClientHints,
|
|
66
|
+
CreateGroupBody,
|
|
67
|
+
CreateProfileBody,
|
|
68
|
+
CreateProxyBody,
|
|
69
|
+
Event,
|
|
70
|
+
AuditReport,
|
|
71
|
+
AuditStatus,
|
|
72
|
+
Extension,
|
|
73
|
+
ExtensionPolicy,
|
|
74
|
+
CredentialBundle,
|
|
75
|
+
CredentialImportResult,
|
|
76
|
+
CredentialMeta,
|
|
77
|
+
ExtensionsResult,
|
|
78
|
+
RevealedCredential,
|
|
79
|
+
VaultStatus,
|
|
80
|
+
GeoMode,
|
|
81
|
+
GpuPersona,
|
|
82
|
+
Group,
|
|
83
|
+
HostMode,
|
|
84
|
+
Locale,
|
|
85
|
+
MediaDevice,
|
|
86
|
+
Metrics,
|
|
87
|
+
MetricsAvailability,
|
|
88
|
+
OsFamily,
|
|
89
|
+
PermissionDefaults,
|
|
90
|
+
ProfileResource,
|
|
91
|
+
Persona,
|
|
92
|
+
PersonaMisc,
|
|
93
|
+
PersonaConstraintOptions,
|
|
94
|
+
Preset,
|
|
95
|
+
PresetConfig,
|
|
96
|
+
PresetConstraints,
|
|
97
|
+
CreatePresetBody,
|
|
98
|
+
UpdatePresetBody,
|
|
99
|
+
Profile,
|
|
100
|
+
Proxy,
|
|
101
|
+
ProxyCheckResult,
|
|
102
|
+
ProxyKind,
|
|
103
|
+
Rotation,
|
|
104
|
+
RuntimeState,
|
|
105
|
+
Screen,
|
|
106
|
+
SessionExportBody,
|
|
107
|
+
SessionExportResult,
|
|
108
|
+
SessionImportBody,
|
|
109
|
+
SessionKind,
|
|
110
|
+
SpeechVoice,
|
|
111
|
+
StartProfileBody,
|
|
112
|
+
StartProfileResult,
|
|
113
|
+
StopProfileResult,
|
|
114
|
+
UpdateProfileBody,
|
|
115
|
+
UpdateProxyBody,
|
|
116
|
+
WebGl,
|
|
117
|
+
WebGpu,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
__all__ = [
|
|
121
|
+
"__version__",
|
|
122
|
+
# runs + activity
|
|
123
|
+
"AgentRun",
|
|
124
|
+
"ActivitySnapshot",
|
|
125
|
+
"GoalSource",
|
|
126
|
+
"OutcomeSource",
|
|
127
|
+
# mailboxes, passkeys, cookies
|
|
128
|
+
"Inbox",
|
|
129
|
+
"InboxBinding",
|
|
130
|
+
"InboxBindings",
|
|
131
|
+
"InboxChannel",
|
|
132
|
+
"PutInboxBody",
|
|
133
|
+
"BindInboxBody",
|
|
134
|
+
"PasskeyRow",
|
|
135
|
+
"PasskeyRetiredReason",
|
|
136
|
+
"RevealedCookie",
|
|
137
|
+
"RevealCookiesBody",
|
|
138
|
+
"RevealCookiesResult",
|
|
139
|
+
# interruptions, artifacts, account, health
|
|
140
|
+
"InterruptionLock",
|
|
141
|
+
"InterruptionChoice",
|
|
142
|
+
"InterruptionAuthor",
|
|
143
|
+
"InterruptionLockRow",
|
|
144
|
+
"InterruptionLockView",
|
|
145
|
+
"InterruptionRuleRow",
|
|
146
|
+
"EffectiveInterruptionLock",
|
|
147
|
+
"SetInterruptionLockBody",
|
|
148
|
+
"SetInterruptionRuleBody",
|
|
149
|
+
"ArtifactBytes",
|
|
150
|
+
"ArtifactPutResult",
|
|
151
|
+
"Account",
|
|
152
|
+
"HealthStatus",
|
|
153
|
+
"ReadyStatus",
|
|
154
|
+
# clients
|
|
155
|
+
"ScalebrowserClient",
|
|
156
|
+
"AsyncScalebrowserClient",
|
|
157
|
+
"SyncCdpSession",
|
|
158
|
+
"CdpSession",
|
|
159
|
+
"connect_cdp",
|
|
160
|
+
"DEFAULT_BASE_URL",
|
|
161
|
+
"DEFAULT_CAPACITY_MAX_CONCURRENT",
|
|
162
|
+
"DEFAULT_CAPACITY_RAM_BUDGET_MB",
|
|
163
|
+
# errors
|
|
164
|
+
"ScalebrowserError",
|
|
165
|
+
"ApiError",
|
|
166
|
+
"NetworkError",
|
|
167
|
+
"CdpError",
|
|
168
|
+
"ErrorCode",
|
|
169
|
+
# events
|
|
170
|
+
"Event",
|
|
171
|
+
"parse_sse_frame",
|
|
172
|
+
# enums
|
|
173
|
+
"RuntimeState",
|
|
174
|
+
"HostMode",
|
|
175
|
+
"GeoMode",
|
|
176
|
+
"ProxyKind",
|
|
177
|
+
"Rotation",
|
|
178
|
+
"SessionKind",
|
|
179
|
+
"OsFamily",
|
|
180
|
+
# persona
|
|
181
|
+
"Persona",
|
|
182
|
+
"Screen",
|
|
183
|
+
"Locale",
|
|
184
|
+
"WebGl",
|
|
185
|
+
"WebGpu",
|
|
186
|
+
"AudioProps",
|
|
187
|
+
"SpeechVoice",
|
|
188
|
+
"GpuPersona",
|
|
189
|
+
"Brand",
|
|
190
|
+
"ClientHints",
|
|
191
|
+
"PermissionDefaults",
|
|
192
|
+
"MediaDevice",
|
|
193
|
+
"PersonaMisc",
|
|
194
|
+
# resources
|
|
195
|
+
"Profile",
|
|
196
|
+
"Group",
|
|
197
|
+
"PersonaConstraintOptions",
|
|
198
|
+
"Preset",
|
|
199
|
+
"PresetConfig",
|
|
200
|
+
"PresetConstraints",
|
|
201
|
+
"CreatePresetBody",
|
|
202
|
+
"UpdatePresetBody",
|
|
203
|
+
"Proxy",
|
|
204
|
+
# payloads
|
|
205
|
+
"CreateProfileBody",
|
|
206
|
+
"UpdateProfileBody",
|
|
207
|
+
"StartProfileBody",
|
|
208
|
+
"StartProfileResult",
|
|
209
|
+
"StopProfileResult",
|
|
210
|
+
"BulkCreateBody",
|
|
211
|
+
"BulkIdsBody",
|
|
212
|
+
"BulkAssignProxyBody",
|
|
213
|
+
"CreateProxyBody",
|
|
214
|
+
"UpdateProxyBody",
|
|
215
|
+
"ProxyCheckResult",
|
|
216
|
+
"CheckProxyConfigBody",
|
|
217
|
+
"AuditReport",
|
|
218
|
+
"AuditStatus",
|
|
219
|
+
"Extension",
|
|
220
|
+
"ExtensionPolicy",
|
|
221
|
+
"CredentialBundle",
|
|
222
|
+
"CredentialImportResult",
|
|
223
|
+
"CredentialMeta",
|
|
224
|
+
"ExtensionsResult",
|
|
225
|
+
"RevealedCredential",
|
|
226
|
+
"VaultStatus",
|
|
227
|
+
"CreateGroupBody",
|
|
228
|
+
"SessionExportBody",
|
|
229
|
+
"SessionExportResult",
|
|
230
|
+
"SessionImportBody",
|
|
231
|
+
"Metrics",
|
|
232
|
+
"MetricsAvailability",
|
|
233
|
+
"ProfileResource",
|
|
234
|
+
]
|
scalebrowser/_http.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""The low-level async HTTP transport. Every REST call goes through here, so
|
|
2
|
+
Bearer auth, query-string building, JSON (de)serialisation and error mapping
|
|
3
|
+
(``{ code, message }`` → :class:`ApiError`) live in exactly one place.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any, AsyncIterator, Mapping, Optional
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .errors import ApiError, NetworkError
|
|
14
|
+
|
|
15
|
+
JsonValue = Any
|
|
16
|
+
"""A decoded JSON document (dict / list / scalar / None)."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _clean_params(params: Optional[Mapping[str, Any]]) -> Optional[dict[str, Any]]:
|
|
20
|
+
if not params:
|
|
21
|
+
return None
|
|
22
|
+
return {k: v for k, v in params.items() if v is not None and v != ""}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _filename_of(disposition: Optional[str]) -> Optional[str]:
|
|
26
|
+
"""``attachment; filename="shot.jpg"`` → ``shot.jpg``."""
|
|
27
|
+
if not disposition:
|
|
28
|
+
return None
|
|
29
|
+
match = re.search(r'filename="([^"]*)"', disposition) or re.search(
|
|
30
|
+
r"filename=([^;]+)", disposition
|
|
31
|
+
)
|
|
32
|
+
name = match.group(1).strip() if match else ""
|
|
33
|
+
return name or None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _safe_json(response: httpx.Response) -> Optional[dict[str, Any]]:
|
|
37
|
+
try:
|
|
38
|
+
data = response.json()
|
|
39
|
+
except ValueError:
|
|
40
|
+
return None
|
|
41
|
+
return data if isinstance(data, dict) else {"message": str(data)}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AsyncTransport:
|
|
45
|
+
"""Wraps an :class:`httpx.AsyncClient` with the daemon's auth + error contract."""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
base_url: str,
|
|
50
|
+
token: Optional[str] = None,
|
|
51
|
+
*,
|
|
52
|
+
timeout: float = 30.0,
|
|
53
|
+
transport: Optional[httpx.AsyncBaseTransport] = None,
|
|
54
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
55
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
self.base_url = base_url.rstrip("/")
|
|
58
|
+
self._token = token
|
|
59
|
+
if http_client is not None:
|
|
60
|
+
self._client = http_client
|
|
61
|
+
self._owns_client = False
|
|
62
|
+
else:
|
|
63
|
+
self._client = httpx.AsyncClient(
|
|
64
|
+
base_url=self.base_url,
|
|
65
|
+
timeout=timeout,
|
|
66
|
+
transport=transport,
|
|
67
|
+
headers=dict(headers) if headers else None,
|
|
68
|
+
)
|
|
69
|
+
self._owns_client = True
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def token(self) -> Optional[str]:
|
|
73
|
+
return self._token
|
|
74
|
+
|
|
75
|
+
def _auth_headers(self, extra: Optional[Mapping[str, str]] = None) -> dict[str, str]:
|
|
76
|
+
headers: dict[str, str] = dict(extra) if extra else {}
|
|
77
|
+
if self._token:
|
|
78
|
+
headers["Authorization"] = f"Bearer {self._token}"
|
|
79
|
+
return headers
|
|
80
|
+
|
|
81
|
+
# ── core request ─────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
async def request(
|
|
84
|
+
self,
|
|
85
|
+
method: str,
|
|
86
|
+
path: str,
|
|
87
|
+
*,
|
|
88
|
+
json: JsonValue = None,
|
|
89
|
+
content: Optional[bytes] = None,
|
|
90
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
91
|
+
) -> JsonValue:
|
|
92
|
+
headers = self._auth_headers()
|
|
93
|
+
if content is not None:
|
|
94
|
+
# A ``.crx`` upload is the file itself, not JSON around it.
|
|
95
|
+
headers["Content-Type"] = "application/octet-stream"
|
|
96
|
+
try:
|
|
97
|
+
response = await self._client.request(
|
|
98
|
+
method,
|
|
99
|
+
path,
|
|
100
|
+
json=json if json is not None else None,
|
|
101
|
+
content=content,
|
|
102
|
+
params=_clean_params(params),
|
|
103
|
+
headers=headers,
|
|
104
|
+
)
|
|
105
|
+
except httpx.HTTPError as exc:
|
|
106
|
+
raise NetworkError("Could not reach the Scalebrowser daemon. Is it running?", exc) from exc
|
|
107
|
+
return self._handle(response)
|
|
108
|
+
|
|
109
|
+
def _handle(self, response: httpx.Response) -> JsonValue:
|
|
110
|
+
if response.status_code >= 400:
|
|
111
|
+
body = _safe_json(response)
|
|
112
|
+
code = body.get("code") if body else None
|
|
113
|
+
message = body.get("message") if body else None
|
|
114
|
+
raise ApiError(response.status_code, code, message, body)
|
|
115
|
+
if response.status_code == 204 or not response.content:
|
|
116
|
+
return None
|
|
117
|
+
return response.json()
|
|
118
|
+
|
|
119
|
+
async def get(self, path: str, *, params: Optional[Mapping[str, Any]] = None) -> JsonValue:
|
|
120
|
+
return await self.request("GET", path, params=params)
|
|
121
|
+
|
|
122
|
+
async def post(self, path: str, body: JsonValue = None) -> JsonValue:
|
|
123
|
+
return await self.request("POST", path, json=body if body is not None else {})
|
|
124
|
+
|
|
125
|
+
async def patch(self, path: str, body: JsonValue = None) -> JsonValue:
|
|
126
|
+
return await self.request("PATCH", path, json=body if body is not None else {})
|
|
127
|
+
|
|
128
|
+
async def put(self, path: str, body: JsonValue = None) -> JsonValue:
|
|
129
|
+
return await self.request("PUT", path, json=body if body is not None else {})
|
|
130
|
+
|
|
131
|
+
async def get_bytes(self, path: str) -> tuple[bytes, Optional[str], Optional[str]]:
|
|
132
|
+
"""Fetch a route that answers with bytes: ``(body, content_type, filename)``.
|
|
133
|
+
|
|
134
|
+
Separate from :meth:`get` rather than a flag on it, because the error
|
|
135
|
+
path differs: a failed byte fetch still answers JSON, so the mapping to
|
|
136
|
+
:class:`ApiError` has to happen before the body is read as binary.
|
|
137
|
+
"""
|
|
138
|
+
try:
|
|
139
|
+
response = await self._client.request("GET", path, headers=self._auth_headers())
|
|
140
|
+
except httpx.HTTPError as exc:
|
|
141
|
+
raise NetworkError(
|
|
142
|
+
"Could not reach the Scalebrowser daemon. Is it running?", exc
|
|
143
|
+
) from exc
|
|
144
|
+
if response.status_code >= 400:
|
|
145
|
+
body = _safe_json(response)
|
|
146
|
+
code = body.get("code") if body else None
|
|
147
|
+
message = body.get("message") if body else None
|
|
148
|
+
raise ApiError(response.status_code, code, message, body)
|
|
149
|
+
return (
|
|
150
|
+
response.content,
|
|
151
|
+
response.headers.get("content-type"),
|
|
152
|
+
_filename_of(response.headers.get("content-disposition")),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
async def delete(self, path: str, body: JsonValue = None) -> JsonValue:
|
|
156
|
+
# A body stays opt-in: most DELETEs carry none, but the extensions
|
|
157
|
+
# endpoint identifies the ref to detach in one (`{ext_ref}`).
|
|
158
|
+
return await self.request("DELETE", path, json=body)
|
|
159
|
+
|
|
160
|
+
# ── server-sent events ───────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
async def stream_sse(self, path: str) -> AsyncIterator[str]:
|
|
163
|
+
"""Yield raw lines of a ``text/event-stream`` body (Bearer attached)."""
|
|
164
|
+
headers = self._auth_headers({"Accept": "text/event-stream"})
|
|
165
|
+
try:
|
|
166
|
+
async with self._client.stream("GET", path, headers=headers) as response:
|
|
167
|
+
if response.status_code >= 400:
|
|
168
|
+
await response.aread()
|
|
169
|
+
body = _safe_json(response)
|
|
170
|
+
code = body.get("code") if body else None
|
|
171
|
+
message = body.get("message") if body else None
|
|
172
|
+
raise ApiError(response.status_code, code, message, body)
|
|
173
|
+
async for line in response.aiter_lines():
|
|
174
|
+
yield line
|
|
175
|
+
except httpx.HTTPError as exc:
|
|
176
|
+
raise NetworkError("Event stream connection failed.", exc) from exc
|
|
177
|
+
|
|
178
|
+
async def aclose(self) -> None:
|
|
179
|
+
if self._owns_client:
|
|
180
|
+
await self._client.aclose()
|