solari-browser 0.1.1__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,60 @@
1
+ """Solari Browser — Python SDK.
2
+
3
+ Managed, stealthy remote Chromium over the Playwright wire protocol / raw CDP.
4
+
5
+ import asyncio
6
+ from solari_browser import Solari
7
+
8
+ async def main():
9
+ async with Solari(api_key="slr_live_...") as solari:
10
+ async with await solari.launch(stealth=True) as browser:
11
+ page = await browser.new_page()
12
+ await page.goto("https://example.com")
13
+ print(await page.title())
14
+
15
+ asyncio.run(main())
16
+ """
17
+
18
+ from .browser_session import BrowserSession
19
+ from .client import Solari, derive_cdp_from_ws
20
+ from .errors import (
21
+ BROWSER_UNHEALTHY,
22
+ CONCURRENCY_LIMIT_EXCEEDED,
23
+ FEATURE_REQUIRES_PLAN,
24
+ PLAN_LIMIT_EXCEEDED,
25
+ SolariError,
26
+ )
27
+ from .types import (
28
+ DEFAULT_REGION,
29
+ REGION_URLS,
30
+ UNSET,
31
+ Profile,
32
+ ProxyRequest,
33
+ ReplayUrl,
34
+ ResolvedProxyConfig,
35
+ SaveResult,
36
+ Session,
37
+ )
38
+
39
+ __version__ = "0.1.0"
40
+
41
+ __all__ = [
42
+ "Solari",
43
+ "BrowserSession",
44
+ "SolariError",
45
+ "Session",
46
+ "Profile",
47
+ "ProxyRequest",
48
+ "ResolvedProxyConfig",
49
+ "ReplayUrl",
50
+ "SaveResult",
51
+ "UNSET",
52
+ "REGION_URLS",
53
+ "DEFAULT_REGION",
54
+ "derive_cdp_from_ws",
55
+ "FEATURE_REQUIRES_PLAN",
56
+ "CONCURRENCY_LIMIT_EXCEEDED",
57
+ "PLAN_LIMIT_EXCEEDED",
58
+ "BROWSER_UNHEALTHY",
59
+ "__version__",
60
+ ]
@@ -0,0 +1,94 @@
1
+ """BrowserSession — a connected browser + its Solari session. Mirrors sdk/src/browser-session.ts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any, List, Optional
6
+
7
+ from .types import ResolvedProxyConfig, Session
8
+
9
+ if TYPE_CHECKING: # pragma: no cover
10
+ from .client import Solari
11
+
12
+
13
+ class BrowserSession:
14
+ """Wraps a patchright `Browser` and the Solari session backing it.
15
+
16
+ `close()` closes the browser AND releases the session — a browser close alone
17
+ would leave the slot held until its plan deadline.
18
+ """
19
+
20
+ def __init__(self, client: "Solari", session: Session, browser: Any) -> None:
21
+ self._client = client
22
+ self._session = session
23
+ self._browser = browser
24
+ self._closed = False
25
+
26
+ @property
27
+ def id(self) -> str:
28
+ return self._session.id
29
+
30
+ @property
31
+ def expires_at(self) -> str:
32
+ """ISO 8601 UTC deadline; the session auto-releases at this point."""
33
+ return self._session.expires_at
34
+
35
+ @property
36
+ def proxy(self) -> Optional[ResolvedProxyConfig]:
37
+ return self._session.proxy
38
+
39
+ @property
40
+ def ws_endpoint(self) -> str:
41
+ """Upstream Playwright wire-protocol endpoint."""
42
+ return self._session.ws_endpoint
43
+
44
+ @property
45
+ def cdp_endpoint(self) -> str:
46
+ """Upstream raw-CDP endpoint.
47
+
48
+ NOTE: driving the browser over raw CDP bypasses the pool's Playwright-path
49
+ input humanization (see CLAUDE.md) — relevant if you care about stealth.
50
+ """
51
+ return self._session.cdp_endpoint
52
+
53
+ @property
54
+ def session(self) -> Session:
55
+ return self._session
56
+
57
+ @property
58
+ def raw(self) -> Any:
59
+ """The underlying patchright `Browser`."""
60
+ return self._browser
61
+
62
+ def is_connected(self) -> bool:
63
+ return bool(self._browser.is_connected())
64
+
65
+ @property
66
+ def version(self) -> str:
67
+ return str(self._browser.version)
68
+
69
+ def contexts(self) -> List[Any]:
70
+ """Existing browser contexts. The default context is `contexts()[0]`."""
71
+ return list(self._browser.contexts)
72
+
73
+ async def new_context(self, **kwargs: Any) -> Any:
74
+ return await self._browser.new_context(**kwargs)
75
+
76
+ async def new_page(self) -> Any:
77
+ return await self._browser.new_page()
78
+
79
+ async def close(self) -> None:
80
+ """Close the browser and release the session. Idempotent."""
81
+ if self._closed:
82
+ return
83
+ self._closed = True
84
+ try:
85
+ await self._browser.close()
86
+ except Exception: # noqa: BLE001 - releasing the slot matters more
87
+ pass
88
+ await self._client.sessions.release_and_wait(self._session.id)
89
+
90
+ async def __aenter__(self) -> "BrowserSession":
91
+ return self
92
+
93
+ async def __aexit__(self, *_exc: Any) -> None:
94
+ await self.close()
@@ -0,0 +1,521 @@
1
+ """Solari Browser client — the async Python port of sdk/src/index.ts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import re
9
+ from typing import Any, Dict, List, Optional, Union
10
+ from urllib.parse import urlsplit, urlunsplit
11
+
12
+ import httpx
13
+
14
+ from .errors import BROWSER_UNHEALTHY, SolariError, code_from_body
15
+ from .types import (
16
+ DEFAULT_REGION,
17
+ REGION_URLS,
18
+ UNSET,
19
+ Profile,
20
+ ProxyRequest,
21
+ ProxySpec,
22
+ ReplayUrl,
23
+ ResolvedProxyConfig,
24
+ SaveResult,
25
+ Session,
26
+ StorageState,
27
+ )
28
+
29
+ log = logging.getLogger("solari_browser")
30
+
31
+ DEFAULT_MAX_ATTEMPTS = 2
32
+ DEFAULT_BACKOFF_MS = 500
33
+ DEFAULT_TIMEOUT_MS = 90_000
34
+ _STORAGE_STATE_TIMEOUT_S = 8.0
35
+
36
+ # Only these are retried. Mirrors `isRetryableStatus` — note this is a FIXED backoff
37
+ # and a short attempt count, unlike the desktop SDK's exponential+jitter.
38
+ _RETRYABLE_STATUS = frozenset({502, 503, 504})
39
+
40
+ # Mirrors `isLikelyTransientConnect`: which connect failures are worth relaunching.
41
+ _TRANSIENT_CONNECT = re.compile(
42
+ r"ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE|socket hang up|WebSocket|connect|"
43
+ r"handshake|Browser closed|Target page, context or browser has been closed",
44
+ re.IGNORECASE,
45
+ )
46
+
47
+
48
+ def derive_cdp_from_ws(ws_endpoint: str) -> str:
49
+ """`/ws/<id>` -> `/cdp/<id>`. Mirrors `deriveCdpFromWs`.
50
+
51
+ The gateway usually returns cdpEndpoint explicitly; this is the fallback for
52
+ older gateways. Returns the input unchanged if it isn't a parseable /ws/ URL.
53
+ """
54
+ try:
55
+ parts = urlsplit(ws_endpoint)
56
+ if parts.path.startswith("/ws/"):
57
+ new_path = "/cdp/" + parts.path[len("/ws/") :]
58
+ return urlunsplit((parts.scheme, parts.netloc, new_path, parts.query, parts.fragment))
59
+ except Exception:
60
+ pass
61
+ return ws_endpoint
62
+
63
+
64
+ class _Sessions:
65
+ """`solari.sessions` — mirrors SessionsResource."""
66
+
67
+ def __init__(self, client: "Solari") -> None:
68
+ self._client = client
69
+
70
+ async def create(
71
+ self,
72
+ *,
73
+ profile_id: Optional[str] = None,
74
+ recording: bool = False,
75
+ stealth: bool = False,
76
+ captcha: bool = False,
77
+ web_bot_auth: bool = False,
78
+ proxy: Optional[ProxySpec] = None,
79
+ ) -> Session:
80
+ """Create a browser session.
81
+
82
+ captcha and proxy require stealth=True (enforced gateway-side).
83
+ """
84
+ body: Dict[str, Any] = {}
85
+ if profile_id:
86
+ body["profileId"] = profile_id
87
+ if recording:
88
+ body["recording"] = True
89
+ if stealth:
90
+ body["stealth"] = True
91
+ if captcha:
92
+ body["captcha"] = True
93
+ if web_bot_auth:
94
+ body["webBotAuth"] = True
95
+ if proxy is not None:
96
+ body["proxy"] = proxy.to_wire() if isinstance(proxy, ProxyRequest) else proxy
97
+
98
+ # An empty body is sent as no body at all, matching the TS SDK.
99
+ res = await self._client.request("POST", "/sessions", body if body else None)
100
+ if res.status_code >= 400:
101
+ text = res.text
102
+ raise SolariError(
103
+ f"Solari POST /sessions failed: {res.status_code} {text}",
104
+ res.status_code,
105
+ None,
106
+ code_from_body(text),
107
+ )
108
+
109
+ data = _json(res, "/sessions")
110
+ session_id = data.get("sessionId")
111
+ ws_endpoint = data.get("wsEndpoint")
112
+ if not session_id or not ws_endpoint:
113
+ raise SolariError(f"Solari: unexpected session response: {json.dumps(data)}")
114
+
115
+ cdp_endpoint = data.get("cdpEndpoint") or derive_cdp_from_ws(ws_endpoint)
116
+ expires_at = data.get("expiresAt")
117
+ if not expires_at:
118
+ # Mirrors the TS fallback: assume the plan's 60m default rather than
119
+ # leaving callers with an unusable empty deadline.
120
+ import datetime as _dt
121
+
122
+ expires_at = (
123
+ _dt.datetime.now(_dt.timezone.utc) + _dt.timedelta(minutes=60)
124
+ ).isoformat().replace("+00:00", "Z")
125
+
126
+ session = Session(
127
+ id=session_id,
128
+ ws_endpoint=ws_endpoint,
129
+ cdp_endpoint=cdp_endpoint,
130
+ expires_at=expires_at,
131
+ )
132
+
133
+ # storage_state is only populated when a profile was requested. UNSET means
134
+ # "no profile attached"; None means "profile exists but is empty".
135
+ if profile_id is not None:
136
+ url_obj = data.get("storageStateUrl")
137
+ if url_obj is not None:
138
+ session.storage_state = await _fetch_presigned_storage_state(url_obj.get("url"))
139
+ else:
140
+ session.storage_state = data.get("storageState")
141
+
142
+ if data.get("proxy"):
143
+ session.proxy = ResolvedProxyConfig.from_wire(data["proxy"])
144
+ return session
145
+
146
+ async def release(self, session_id: str) -> None:
147
+ """Best-effort release. Never raises — use `release_and_wait` for confirmation."""
148
+ try:
149
+ res = await self._client.request(
150
+ "DELETE", f"/sessions/{_q(session_id)}"
151
+ )
152
+ if res.status_code >= 400 and res.status_code != 404:
153
+ log.warning(
154
+ "[Solari] DELETE /sessions/%s returned %s: %s",
155
+ session_id,
156
+ res.status_code,
157
+ res.text[:256],
158
+ )
159
+ except Exception as err: # noqa: BLE001 - fire-and-forget by contract
160
+ log.warning("[Solari] DELETE /sessions/%s failed (best-effort): %s", session_id, err)
161
+
162
+ async def release_and_wait(self, session_id: str) -> None:
163
+ """Release and confirm. A 404 is success — the session is already gone."""
164
+ res = await self._client.request("DELETE", f"/sessions/{_q(session_id)}")
165
+ if res.status_code >= 400 and res.status_code != 404:
166
+ raise SolariError(
167
+ f"Solari DELETE /sessions/{session_id} failed: {res.status_code} {res.text}",
168
+ res.status_code,
169
+ )
170
+
171
+ async def get(self, session_id: str) -> Dict[str, Any]:
172
+ """Raw session view from the gateway."""
173
+ res = await self._client.request("GET", f"/sessions/{_q(session_id)}")
174
+ if res.status_code >= 400:
175
+ raise SolariError(
176
+ f"Solari GET /sessions/{session_id} failed: {res.status_code} {res.text}",
177
+ res.status_code,
178
+ )
179
+ return _json(res, f"/sessions/{session_id}")
180
+
181
+ async def get_replay_url(self, session_id: str) -> ReplayUrl:
182
+ """Presigned replay URL. Available ~1-3s after `release_and_wait`."""
183
+ res = await self._client.request("GET", f"/sessions/{_q(session_id)}/replay-url")
184
+ if res.status_code >= 400:
185
+ raise SolariError(
186
+ f"Solari GET /sessions/{session_id}/replay-url failed: "
187
+ f"{res.status_code} {res.text}",
188
+ res.status_code,
189
+ )
190
+ data = _json(res, "replay-url")
191
+ if not data.get("url"):
192
+ raise SolariError(f"Solari: unexpected replay-url response: {json.dumps(data)}")
193
+ return ReplayUrl(
194
+ url=data["url"],
195
+ expires_in_seconds=data.get("expiresInSeconds") or 0,
196
+ content_encoding=data.get("contentEncoding") or "gzip",
197
+ )
198
+
199
+ async def download_replay(self, session_id: str) -> bytes:
200
+ """Download the replay as NDJSON bytes (gzip-encoded per `content_encoding`)."""
201
+ replay = await self.get_replay_url(session_id)
202
+ async with httpx.AsyncClient(timeout=self._client._timeout_s) as http:
203
+ res = await http.get(replay.url)
204
+ if res.status_code >= 400:
205
+ raise SolariError(
206
+ f"Solari: replay download failed: {res.status_code}", res.status_code
207
+ )
208
+ return res.content
209
+
210
+
211
+ class _Profiles:
212
+ """`solari.profiles` — mirrors ProfilesResource."""
213
+
214
+ def __init__(self, client: "Solari") -> None:
215
+ self._client = client
216
+
217
+ async def create(self, name: str) -> Profile:
218
+ res = await self._client.request("POST", "/profiles", {"name": name})
219
+ if res.status_code >= 400:
220
+ text = res.text
221
+ raise SolariError(
222
+ f"Solari POST /profiles failed: {res.status_code} {text}",
223
+ res.status_code,
224
+ None,
225
+ code_from_body(text),
226
+ )
227
+ return Profile.from_wire(_json(res, "/profiles"))
228
+
229
+ async def list(self) -> List[Profile]:
230
+ res = await self._client.request("GET", "/profiles")
231
+ if res.status_code >= 400:
232
+ raise SolariError(
233
+ f"Solari GET /profiles failed: {res.status_code} {res.text}", res.status_code
234
+ )
235
+ data = _json(res, "/profiles")
236
+ items = data if isinstance(data, list) else data.get("profiles", [])
237
+ return [Profile.from_wire(p) for p in items]
238
+
239
+ async def delete(self, profile_id: str) -> None:
240
+ """Delete a profile. A 404 is success — it's already gone."""
241
+ res = await self._client.request("DELETE", f"/profiles/{_q(profile_id)}")
242
+ if res.status_code >= 400 and res.status_code != 404:
243
+ raise SolariError(
244
+ f"Solari DELETE /profiles/{profile_id} failed: {res.status_code} {res.text}",
245
+ res.status_code,
246
+ )
247
+
248
+ async def save(self, profile_id: str, storage_state: StorageState) -> SaveResult:
249
+ """Persist a Playwright storage state into the profile."""
250
+ res = await self._client.request(
251
+ "POST", f"/profiles/{_q(profile_id)}/save", {"storageState": storage_state}
252
+ )
253
+ if res.status_code >= 400:
254
+ raise SolariError(
255
+ f"Solari POST /profiles/{profile_id}/save failed: {res.status_code} {res.text}",
256
+ res.status_code,
257
+ )
258
+ data = _json(res, "profiles/save")
259
+ return SaveResult(
260
+ version=data.get("version") or 0,
261
+ size_bytes=data.get("sizeBytes") or 0,
262
+ )
263
+
264
+
265
+ class Solari:
266
+ """Solari Browser client.
267
+
268
+ solari = Solari(api_key="slr_live_...")
269
+ browser = await solari.launch(stealth=True)
270
+ page = await browser.new_page()
271
+ await page.goto("https://example.com")
272
+ await browser.close()
273
+ await solari.close()
274
+
275
+ Or as an async context manager:
276
+
277
+ async with Solari(api_key="slr_live_...") as solari:
278
+ async with await solari.launch(stealth=True) as browser:
279
+ ...
280
+ """
281
+
282
+ def __init__(
283
+ self,
284
+ api_key: str,
285
+ *,
286
+ region: str = DEFAULT_REGION,
287
+ base_url: Optional[str] = None,
288
+ max_attempts: int = DEFAULT_MAX_ATTEMPTS,
289
+ backoff_ms: int = DEFAULT_BACKOFF_MS,
290
+ timeout_ms: int = DEFAULT_TIMEOUT_MS,
291
+ ) -> None:
292
+ if not api_key:
293
+ raise SolariError("Solari: api_key is required")
294
+ region_url = REGION_URLS.get(region)
295
+ if not region_url:
296
+ raise SolariError(
297
+ f'Solari: unsupported region "{region}". '
298
+ f"Supported: {', '.join(REGION_URLS)}."
299
+ )
300
+ self._api_key = api_key
301
+ # base_url wins over region entirely — that's how you point at staging or a
302
+ # self-hosted gateway.
303
+ self._base_url = (base_url or region_url).rstrip("/")
304
+ self._max_attempts = max_attempts
305
+ self._backoff_ms = backoff_ms
306
+ self._timeout_s = timeout_ms / 1000.0
307
+
308
+ self.sessions = _Sessions(self)
309
+ self.profiles = _Profiles(self)
310
+
311
+ self._http: Optional[httpx.AsyncClient] = None
312
+ self._playwright: Any = None # lazily started; only for launch()
313
+
314
+ @property
315
+ def base_url(self) -> str:
316
+ return self._base_url
317
+
318
+ async def __aenter__(self) -> "Solari":
319
+ return self
320
+
321
+ async def __aexit__(self, *_exc: Any) -> None:
322
+ await self.close()
323
+
324
+ async def close(self) -> None:
325
+ """Release the HTTP client and (if launch() was used) the patchright driver."""
326
+ if self._http is not None:
327
+ await self._http.aclose()
328
+ self._http = None
329
+ if self._playwright is not None:
330
+ await self._playwright.stop()
331
+ self._playwright = None
332
+
333
+ async def request(
334
+ self, method: str, path: str, body: Optional[Any] = None
335
+ ) -> httpx.Response:
336
+ """Authenticated request with the SDK's retry policy.
337
+
338
+ Retries ONLY 502/503/504 and transport errors, with a FIXED backoff. A
339
+ non-retryable error status is RETURNED, not raised — callers parse `code`
340
+ out of the body themselves (mirrors the TS SDK).
341
+ """
342
+ if self._http is None:
343
+ self._http = httpx.AsyncClient(timeout=self._timeout_s)
344
+ url = f"{self._base_url}{path}"
345
+ headers = {
346
+ "Authorization": f"Bearer {self._api_key}",
347
+ "Content-Type": "application/json",
348
+ }
349
+ payload = None if body is None else json.dumps(body)
350
+
351
+ last_err: Optional[BaseException] = None
352
+ for attempt in range(1, self._max_attempts + 1):
353
+ try:
354
+ res = await self._http.request(
355
+ method, url, headers=headers, content=payload
356
+ )
357
+ if res.status_code < 400 or res.status_code not in _RETRYABLE_STATUS:
358
+ return res
359
+ last_err = SolariError(f"Solari {method} {path}: {res.status_code}", res.status_code)
360
+ except Exception as err: # noqa: BLE001 - all transport errors are retryable
361
+ last_err = err
362
+
363
+ if attempt < self._max_attempts:
364
+ await asyncio.sleep(self._backoff_ms / 1000.0)
365
+
366
+ raise SolariError(
367
+ f"Solari {method} {path}: exhausted {self._max_attempts} attempts",
368
+ None,
369
+ last_err,
370
+ )
371
+
372
+ async def launch(
373
+ self,
374
+ *,
375
+ profile_id: Optional[str] = None,
376
+ recording: bool = False,
377
+ stealth: bool = False,
378
+ captcha: bool = False,
379
+ web_bot_auth: bool = False,
380
+ proxy: Optional[ProxySpec] = None,
381
+ retries: int = 0,
382
+ probe: Optional[bool] = None,
383
+ probe_timeout_ms: int = 2_000,
384
+ ) -> "BrowserSession":
385
+ """Create a session and return a connected browser.
386
+
387
+ This is the full-parity path: it connects over the Playwright wire protocol
388
+ via patchright, exactly like the TS SDK. Go/Rust/C++ cannot do this — there
389
+ is no Playwright client for them.
390
+ """
391
+ from .browser_session import BrowserSession # local import: avoids cycle
392
+
393
+ retries = max(0, retries)
394
+ probe_enabled = (retries > 0) if probe is None else probe
395
+ probe_timeout_ms = max(1, probe_timeout_ms)
396
+ total_attempts = retries + 1
397
+
398
+ chromium = await self._chromium()
399
+
400
+ last_err: Optional[BaseException] = None
401
+ for attempt in range(1, total_attempts + 1):
402
+ session = await self.sessions.create(
403
+ profile_id=profile_id,
404
+ recording=recording,
405
+ stealth=stealth,
406
+ captcha=captcha,
407
+ web_bot_auth=web_bot_auth,
408
+ proxy=proxy,
409
+ )
410
+ browser = None
411
+ try:
412
+ browser = await chromium.connect(session.ws_endpoint)
413
+ if probe_enabled:
414
+ await self._probe_browser_health(browser, probe_timeout_ms)
415
+ return BrowserSession(self, session, browser)
416
+ except Exception as err: # noqa: BLE001
417
+ if browser is not None:
418
+ try:
419
+ await browser.close()
420
+ except Exception: # noqa: BLE001
421
+ pass
422
+ await self.sessions.release(session.id)
423
+
424
+ last_err = err
425
+ is_health = isinstance(err, SolariError) and err.code == BROWSER_UNHEALTHY
426
+ transient = (not is_health) and bool(_TRANSIENT_CONNECT.search(str(err)))
427
+ if attempt < total_attempts and (is_health or transient):
428
+ await asyncio.sleep(0.1 * attempt)
429
+ continue
430
+ raise
431
+
432
+ raise last_err or SolariError("Solari.launch: exhausted attempts")
433
+
434
+ async def _chromium(self) -> Any:
435
+ """Start the patchright driver once and reuse it across launches."""
436
+ if self._playwright is None:
437
+ try:
438
+ from patchright.async_api import async_playwright
439
+ except ImportError as err: # pragma: no cover - depends on install extras
440
+ raise SolariError(
441
+ "Solari: launch() needs patchright. Install it with "
442
+ "`pip install 'solari-browser'` (it is a declared dependency) — "
443
+ "then `patchright install chromium` is NOT required, since the "
444
+ "browser runs remotely.",
445
+ None,
446
+ err,
447
+ ) from err
448
+ self._playwright = await async_playwright().start()
449
+ return self._playwright.chromium
450
+
451
+ async def _probe_browser_health(self, browser: Any, timeout_ms: int) -> None:
452
+ """Prove the browser actually works before handing it back.
453
+
454
+ A slot can accept the WS connect and still be wedged; without this, launch()
455
+ returns a browser that fails on first use. Any failure is normalised to
456
+ BROWSER_UNHEALTHY so launch()'s retry loop can act on it.
457
+ """
458
+
459
+ async def probe() -> None:
460
+ ctx = await browser.new_context()
461
+ try:
462
+ page = await ctx.new_page()
463
+ await page.evaluate("1")
464
+ finally:
465
+ try:
466
+ await ctx.close()
467
+ except Exception: # noqa: BLE001
468
+ pass
469
+
470
+ try:
471
+ await asyncio.wait_for(probe(), timeout=timeout_ms / 1000.0)
472
+ except asyncio.TimeoutError as err:
473
+ raise SolariError(
474
+ f"Solari: browser health probe timed out after {timeout_ms}ms",
475
+ None,
476
+ err,
477
+ BROWSER_UNHEALTHY,
478
+ ) from err
479
+ except Exception as err: # noqa: BLE001
480
+ if isinstance(err, SolariError) and err.code == BROWSER_UNHEALTHY:
481
+ raise
482
+ raise SolariError(
483
+ f"Solari: browser health probe failed: {err}", None, err, BROWSER_UNHEALTHY
484
+ ) from err
485
+
486
+
487
+ def _q(v: str) -> str:
488
+ from urllib.parse import quote
489
+
490
+ return quote(v, safe="")
491
+
492
+
493
+ def _json(res: httpx.Response, what: str) -> Any:
494
+ try:
495
+ return res.json()
496
+ except Exception as err: # noqa: BLE001
497
+ raise SolariError(
498
+ f"Solari: {what} response was not valid JSON: {res.text[:256]}", res.status_code, err
499
+ ) from err
500
+
501
+
502
+ async def _fetch_presigned_storage_state(url: Optional[str]) -> Optional[StorageState]:
503
+ """GET the presigned storage-state object. `None` url means an empty profile."""
504
+ if not url:
505
+ return None
506
+ try:
507
+ async with httpx.AsyncClient(timeout=_STORAGE_STATE_TIMEOUT_S) as http:
508
+ res = await http.get(url)
509
+ except Exception as err: # noqa: BLE001
510
+ raise SolariError(f"Solari: failed to fetch storageState: {err}", None, err) from err
511
+ if res.status_code >= 400:
512
+ raise SolariError(
513
+ f"Solari: storageState fetch returned {res.status_code}: {res.text[:256]}",
514
+ res.status_code,
515
+ )
516
+ try:
517
+ return res.json()
518
+ except Exception as err: # noqa: BLE001
519
+ raise SolariError(
520
+ f"Solari: storageState response was not valid JSON: {err}", None, err
521
+ ) from err
@@ -0,0 +1,68 @@
1
+ """Errors for the Solari Browser SDK. Mirrors `SolariError` in sdk/src/index.ts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Optional
6
+
7
+ # Codes the gateway returns in the `code` field of an error body. Mirrors
8
+ # `SolariErrorCode` (sdk/src/index.ts). Kept as plain strings, not an Enum: the
9
+ # gateway may add codes we don't know yet, and an Enum would raise on those
10
+ # rather than pass them through to the caller.
11
+ FEATURE_REQUIRES_PLAN = "FeatureRequiresPlan"
12
+ CONCURRENCY_LIMIT_EXCEEDED = "ConcurrencyLimitExceeded"
13
+ PLAN_LIMIT_EXCEEDED = "PlanLimitExceeded"
14
+ BROWSER_UNHEALTHY = "BrowserUnhealthy"
15
+
16
+
17
+ class SolariError(Exception):
18
+ """Raised for every Solari failure.
19
+
20
+ Attributes:
21
+ status: HTTP status, when the failure came from an HTTP response.
22
+ cause: The underlying exception, when there was one.
23
+ code: Gateway error code (see the constants above), when the response
24
+ body carried one. `code == BROWSER_UNHEALTHY` is what makes
25
+ `launch(retries=...)` retry.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ message: str,
31
+ status: Optional[int] = None,
32
+ cause: Optional[BaseException] = None,
33
+ code: Optional[str] = None,
34
+ ) -> None:
35
+ super().__init__(message)
36
+ self.status = status
37
+ self.cause = cause
38
+ self.code = code
39
+
40
+ def __str__(self) -> str: # pragma: no cover - trivial
41
+ base = super().__str__()
42
+ bits = []
43
+ if self.status is not None:
44
+ bits.append(f"status={self.status}")
45
+ if self.code:
46
+ bits.append(f"code={self.code}")
47
+ return f"{base} ({', '.join(bits)})" if bits else base
48
+
49
+
50
+ def code_from_body(text: str) -> Optional[str]:
51
+ """Best-effort extract of `{"code": "..."}` from an error body.
52
+
53
+ The gateway returns a JSON body with a `code` on plan/concurrency errors, but
54
+ error bodies are not guaranteed to be JSON at all (proxies, 502 HTML pages),
55
+ so every failure here is swallowed — a missing code must never mask the real
56
+ HTTP error.
57
+ """
58
+ import json
59
+
60
+ try:
61
+ parsed: Any = json.loads(text)
62
+ except Exception:
63
+ return None
64
+ if isinstance(parsed, dict):
65
+ code = parsed.get("code")
66
+ if isinstance(code, str):
67
+ return code
68
+ return None
@@ -0,0 +1,141 @@
1
+ """Wire types for the Solari Browser SDK. Mirrors the interfaces in sdk/src/index.ts.
2
+
3
+ Dataclasses (not TypedDicts) so callers get attribute access + IDE completion, with
4
+ `from_wire` classmethods doing the camelCase→snake_case mapping in one place.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Dict, List, Literal, Optional, Union
11
+
12
+ # Supported regions. More coming soon. Mirrors `SolariRegion`.
13
+ Region = Literal["us-west"]
14
+ REGION_URLS: Dict[str, str] = {
15
+ "us-west": "https://api.getsolari.com",
16
+ }
17
+ DEFAULT_REGION: str = "us-west"
18
+
19
+ ProxyTier = Literal["residential", "static", "mobile"]
20
+
21
+ # A Playwright storage state (cookies + origins/localStorage). Left as a plain dict:
22
+ # it is passed straight through to patchright's `storage_state`, so imposing our own
23
+ # schema would only risk drifting from Playwright's.
24
+ StorageState = Dict[str, Any]
25
+
26
+
27
+ @dataclass
28
+ class ProxyRequest:
29
+ """Managed proxy egress request. Mirrors `ProxyRequest`."""
30
+
31
+ country: Optional[str] = None # ISO-3166-1 alpha-2, lowercase. Defaults to "us".
32
+ tier: Optional[ProxyTier] = None # residential (default, rotating) | static | mobile
33
+ asn: Optional[str] = None # pin egress ASN, e.g. "20057" (AT&T Mobility)
34
+ session: Optional[str] = None # sticky id, alnum + dash, <=32 chars
35
+ session_duration: Optional[int] = None # sticky lifetime, minutes (1-30, default 10)
36
+ state: Optional[str] = None # US-only, e.g. "california"
37
+ city: Optional[str] = None # US-only, e.g. "los_angeles"
38
+
39
+ def to_wire(self) -> Dict[str, Any]:
40
+ """Only non-None keys are sent — the gateway distinguishes absent from null."""
41
+ wire: Dict[str, Any] = {}
42
+ if self.country is not None:
43
+ wire["country"] = self.country
44
+ if self.tier is not None:
45
+ wire["tier"] = self.tier
46
+ if self.asn is not None:
47
+ wire["asn"] = self.asn
48
+ if self.session is not None:
49
+ wire["session"] = self.session
50
+ if self.session_duration is not None:
51
+ wire["sessionDuration"] = self.session_duration
52
+ if self.state is not None:
53
+ wire["state"] = self.state
54
+ if self.city is not None:
55
+ wire["city"] = self.city
56
+ return wire
57
+
58
+
59
+ # `proxy=` accepts the same shapes as the TS SDK: a country string ("us"), a
60
+ # ProxyRequest, or the "off"/"smart" sentinels.
61
+ ProxySpec = Union[str, ProxyRequest]
62
+
63
+
64
+ @dataclass
65
+ class ResolvedProxyConfig:
66
+ """Resolved proxy credentials returned on the session response."""
67
+
68
+ server: str
69
+ username: str
70
+ password: str
71
+ timezone_id: str
72
+ country: str
73
+ tier: Optional[ProxyTier] = None
74
+
75
+ @classmethod
76
+ def from_wire(cls, d: Dict[str, Any]) -> "ResolvedProxyConfig":
77
+ return cls(
78
+ server=d.get("server", ""),
79
+ username=d.get("username", ""),
80
+ password=d.get("password", ""),
81
+ timezone_id=d.get("timezoneId", ""),
82
+ country=d.get("country", ""),
83
+ tier=d.get("tier"),
84
+ )
85
+
86
+
87
+ # Sentinel distinguishing "no profile attached" from "profile exists but empty".
88
+ # The TS SDK encodes this as undefined vs null on `Session.storageState`; Python has
89
+ # only None, so absence is this sentinel and empty-profile is None.
90
+ class _Unset:
91
+ def __repr__(self) -> str: # pragma: no cover - trivial
92
+ return "UNSET"
93
+
94
+ def __bool__(self) -> bool:
95
+ return False
96
+
97
+
98
+ UNSET = _Unset()
99
+
100
+
101
+ @dataclass
102
+ class Session:
103
+ """A live browser session.
104
+
105
+ NOTE: unlike the TS SDK, `ws_endpoint`/`cdp_endpoint` are the UPSTREAM gateway
106
+ URLs. The TS SDK rewrites them to a loopback LocalProxy for connect-retry
107
+ purposes; that is a Node-specific device and is deliberately not reproduced here.
108
+ """
109
+
110
+ id: str
111
+ ws_endpoint: str # Playwright wire protocol — for chromium.connect()
112
+ cdp_endpoint: str # raw CDP — for connect_over_cdp() / other CDP clients
113
+ expires_at: str # ISO 8601 UTC; session auto-releases at this point
114
+ storage_state: Union[StorageState, None, _Unset] = UNSET
115
+ proxy: Optional[ResolvedProxyConfig] = None
116
+
117
+
118
+ @dataclass
119
+ class Profile:
120
+ """A stored browser profile (cookies + localStorage)."""
121
+
122
+ id: str
123
+ name: str
124
+ raw: Dict[str, Any] = field(default_factory=dict) # full wire object, forward-compatible
125
+
126
+ @classmethod
127
+ def from_wire(cls, d: Dict[str, Any]) -> "Profile":
128
+ return cls(id=d.get("id", ""), name=d.get("name", ""), raw=d)
129
+
130
+
131
+ @dataclass
132
+ class ReplayUrl:
133
+ url: str
134
+ expires_in_seconds: int = 0
135
+ content_encoding: str = "gzip"
136
+
137
+
138
+ @dataclass
139
+ class SaveResult:
140
+ version: int = 0
141
+ size_bytes: int = 0
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: solari-browser
3
+ Version: 0.1.1
4
+ Summary: Python SDK for Solari Browser — managed, stealthy remote Chromium over the Playwright wire protocol / raw CDP.
5
+ Project-URL: Homepage, https://getsolari.com
6
+ Author: Solari
7
+ License: Apache-2.0
8
+ Keywords: automation,browser,cdp,playwright,scraping,solari,stealth
9
+ Requires-Python: >=3.9
10
+ Requires-Dist: httpx>=0.24
11
+ Requires-Dist: patchright<1.60,>=1.59
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=7; extra == 'dev'
14
+ Requires-Dist: ruff>=0.1; extra == 'dev'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # solari-browser (Python)
18
+
19
+ Python SDK for **Solari Browser** — managed, stealth-hardened remote Chromium as
20
+ an API, driven over the Playwright wire protocol (or raw CDP). A faithful port of
21
+ the TypeScript `@solarisdk/browser` package; class, method, and field names match
22
+ it one-for-one (snake_case where TS uses camelCase).
23
+
24
+ The browser runs remotely on Solari's pool — `pip install` pulls the driver only;
25
+ you do **not** run `patchright install chromium` locally.
26
+
27
+ ## Install
28
+
29
+ ```sh
30
+ pip install solari-browser
31
+ ```
32
+
33
+ ## Quickstart
34
+
35
+ ```python
36
+ import asyncio, os
37
+ from solari_browser import Solari
38
+
39
+ async def main():
40
+ solari = Solari(api_key=os.environ["SOLARI_API_KEY"])
41
+ browser = await solari.launch(stealth=True) # managed, stealth-hardened Chromium
42
+ page = await browser.new_page()
43
+ await page.goto("https://example.com")
44
+ print(await page.title())
45
+ await browser.close()
46
+
47
+ asyncio.run(main())
48
+ ```
49
+
50
+ ## Sessions
51
+
52
+ Drive a session yourself instead of `launch()`:
53
+
54
+ ```python
55
+ solari = Solari(api_key=os.environ["SOLARI_API_KEY"])
56
+ session = await solari.sessions.create(stealth=True, proxy="smart")
57
+ # ... connect over the session's CDP/WS endpoint ...
58
+ await solari.close()
59
+ ```
60
+
61
+ `Solari(...)` accepts `api_key`, `region` (default `us-west`), and `base_url`
62
+ (default `https://api.getsolari.com`).
63
+
64
+ ## Docs
65
+
66
+ Full reference and language guides: <https://getsolari.com>
@@ -0,0 +1,8 @@
1
+ solari_browser/__init__.py,sha256=GjqnjKtFeqZFVVz63cXAKoWvgCHIooLcoP-BFWhbUgw,1329
2
+ solari_browser/browser_session.py,sha256=Dfm-4t-yU_wdRdlDD7cwxc5_ax0x2qsxf8KsdWsmf-o,2840
3
+ solari_browser/client.py,sha256=XMRiUeLG_oYGStRFlsZ13P3xYTheBQUi-snYQ2ub9NE,19674
4
+ solari_browser/errors.py,sha256=56M753QaLnV_pKaV_Tg18tllJpWoyXwzbPkx-0Izug4,2319
5
+ solari_browser/types.py,sha256=CVY9vXlIbGLbpZa2-ILpZ75W_uXx0gxIBiEhLKnCfCk,4664
6
+ solari_browser-0.1.1.dist-info/METADATA,sha256=LwWa8ip1jkmu-qg3JfFd7LaAKTsxv0qjumcKEhsunLU,1943
7
+ solari_browser-0.1.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
8
+ solari_browser-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any