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.
@@ -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()