solari-browserbase-compat 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.
@@ -0,0 +1,22 @@
1
+ """Browserbase-style Python SDK surface for Solari Browser. See COMPAT.md for what is and is not supported."""
2
+ from .client import (
3
+ AsyncBrowserbase,
4
+ Browserbase,
5
+ Context,
6
+ ContextCreateResponse,
7
+ DEFAULT_BASE_URL,
8
+ Session,
9
+ SessionCreateResponse,
10
+ SessionLiveURLs,
11
+ SessionRetrieveResponse,
12
+ )
13
+ from .errors import APIError, BrowserbaseError, SolariCompatUnsupported
14
+ from .mapping import map_create_params, map_status
15
+
16
+ __version__ = "0.1.0"
17
+
18
+ __all__ = [
19
+ "AsyncBrowserbase", "Browserbase", "APIError", "BrowserbaseError", "SolariCompatUnsupported",
20
+ "Context", "ContextCreateResponse", "Session", "SessionCreateResponse", "SessionRetrieveResponse",
21
+ "SessionLiveURLs", "DEFAULT_BASE_URL", "map_create_params", "map_status",
22
+ ]
@@ -0,0 +1,378 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import gzip
5
+ import json
6
+ import os
7
+ import time
8
+ import uuid
9
+ import warnings as _warnings
10
+ from dataclasses import dataclass, field
11
+ from typing import Any, Callable, Dict, List, Optional
12
+
13
+ import httpx
14
+
15
+ from .errors import APIError, BrowserbaseError, SolariCompatUnsupported
16
+ from .mapping import map_create_params, map_status
17
+
18
+ DEFAULT_BASE_URL = "https://api.getsolari.com"
19
+ REGION = "us-west-2" # reported on Browserbase-shaped objects; Solari's single region (us-west)
20
+
21
+
22
+ @dataclass
23
+ class Session:
24
+ id: str
25
+ created_at: str
26
+ updated_at: str
27
+ project_id: str
28
+ started_at: str
29
+ expires_at: str
30
+ status: str
31
+ proxy_bytes: int
32
+ keep_alive: bool
33
+ region: str
34
+ ended_at: Optional[str] = None
35
+ context_id: Optional[str] = None
36
+ user_metadata: Optional[Dict[str, Any]] = None
37
+
38
+
39
+ @dataclass
40
+ class SessionCreateResponse(Session):
41
+ # CDP websocket URL: playwright `connect_over_cdp(connect_url)` / any CDP client.
42
+ connect_url: str = ""
43
+ selenium_remote_url: str = "" # always empty: no WebDriver endpoint
44
+ signing_key: str = "" # always empty: the session id in the URL is the credential
45
+ # Solari extension. For a session created with browserSettings.context: the saved cookies/localStorage.
46
+ # Solari applies a saved context only to a browser context created with it, so create yours with
47
+ # browser.new_context(storage_state=session.storage_state). None when no context was requested.
48
+ storage_state: Optional[Dict[str, Any]] = None
49
+
50
+
51
+ SessionRetrieveResponse = SessionCreateResponse
52
+
53
+
54
+ @dataclass
55
+ class SessionLiveURLs:
56
+ debugger_fullscreen_url: str
57
+ debugger_url: str
58
+ ws_url: str
59
+ pages: List[Dict[str, Any]] = field(default_factory=list)
60
+
61
+
62
+ @dataclass
63
+ class Context:
64
+ id: str
65
+ name: Optional[str] = None
66
+ created_at: Optional[str] = None
67
+
68
+
69
+ @dataclass
70
+ class ContextCreateResponse:
71
+ id: str
72
+ public_key: str = "" # empty: Solari profiles are stored server-side
73
+ cipher_algorithm: str = ""
74
+ initialization_vector_size: int = 0
75
+
76
+
77
+ class _Unsupported:
78
+ """Every attribute is a callable that raises SolariCompatUnsupported."""
79
+
80
+ def __init__(self, feature: str, hint: str) -> None:
81
+ self._feature = feature
82
+ self._hint = hint
83
+
84
+ def __getattr__(self, name: str) -> Callable[..., Any]:
85
+ if name.startswith("__"):
86
+ raise AttributeError(name)
87
+
88
+ def _raise(*_a: Any, **_k: Any) -> Any:
89
+ raise SolariCompatUnsupported(f"{self._feature}.{name}", self._hint)
90
+
91
+ return _raise
92
+
93
+
94
+ def _resolve_api_key(explicit: Optional[str], warn: Callable[[str], None]) -> str:
95
+ for name, v in (
96
+ ("api_key", explicit),
97
+ ("SOLARI_API_KEY", os.environ.get("SOLARI_API_KEY")),
98
+ ("BROWSERBASE_API_KEY", os.environ.get("BROWSERBASE_API_KEY")),
99
+ ):
100
+ if not v:
101
+ continue
102
+ if v.startswith("slr_"):
103
+ return v
104
+ # Never echo any part of the value: it is probably a Browserbase secret.
105
+ warn(f"{name} does not look like a Solari key (expected the slr_ prefix); ignoring it. It was not sent anywhere.")
106
+ raise BrowserbaseError("No Solari API key: pass api_key or set SOLARI_API_KEY (keys start with slr_)")
107
+
108
+
109
+ def _default_warn(msg: str) -> None:
110
+ _warnings.warn(f"[solari-browserbase-compat] {msg}", stacklevel=3)
111
+
112
+
113
+ def _session(row: Dict[str, Any], *, status: Optional[str] = None, keep_alive: bool = False, context_id: Optional[str] = None) -> Session:
114
+ created = row.get("createdAt") or time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
115
+ st = status or map_status(row.get("status"))
116
+ return Session(
117
+ id=row["id"],
118
+ created_at=created,
119
+ updated_at=created,
120
+ project_id="",
121
+ started_at=created,
122
+ ended_at=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) if st in ("COMPLETED", "TIMED_OUT") else None,
123
+ expires_at=row.get("expiresAt") or created,
124
+ status=st,
125
+ proxy_bytes=0,
126
+ keep_alive=keep_alive,
127
+ region=REGION,
128
+ context_id=context_id,
129
+ )
130
+
131
+
132
+ class _Http:
133
+ def __init__(self, api_key: str, base_url: str, timeout: float, max_retries: int, transport: Optional[httpx.BaseTransport]) -> None:
134
+ self.api_key = api_key
135
+ self.base_url = base_url.rstrip("/")
136
+ self.max_retries = max_retries
137
+ self.client = httpx.Client(timeout=timeout, transport=transport)
138
+
139
+ def request(self, method: str, path: str, body: Any = None, idempotency_key: Optional[str] = None) -> httpx.Response:
140
+ headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
141
+ if idempotency_key:
142
+ headers["Idempotency-Key"] = idempotency_key
143
+ replay_safe = method in ("GET", "DELETE") or idempotency_key is not None
144
+ last: Optional[Exception] = None
145
+ for attempt in range(self.max_retries + 1):
146
+ try:
147
+ res = self.client.request(method, f"{self.base_url}{path}", headers=headers, content=None if body is None else json.dumps(body))
148
+ if res.is_success or not replay_safe or res.status_code not in (502, 503, 504) or attempt == self.max_retries:
149
+ return res
150
+ except httpx.TransportError as e:
151
+ if not replay_safe or attempt == self.max_retries:
152
+ raise
153
+ last = e
154
+ time.sleep(0.3 * (attempt + 1))
155
+ raise last or BrowserbaseError("request failed")
156
+
157
+ def json(self, method: str, path: str, body: Any = None, idempotency_key: Optional[str] = None) -> Any:
158
+ res = self.request(method, path, body, idempotency_key)
159
+ if res.status_code == 204:
160
+ return None
161
+ text = res.text
162
+ try:
163
+ parsed = json.loads(text) if text else None
164
+ except ValueError:
165
+ parsed = None
166
+ if not res.is_success:
167
+ o = parsed if isinstance(parsed, dict) else {}
168
+ raise APIError(res.status_code, f"Solari {method} {path}: {res.status_code} {o.get('error') or text[:200]}", o.get("code"), parsed)
169
+ return parsed
170
+
171
+
172
+ class _Recording:
173
+ def __init__(self, http: _Http) -> None:
174
+ self._http = http
175
+
176
+ def retrieve(self, id: str) -> List[Any]:
177
+ """The session's DOM recording as a list of rrweb events (needs ``recordSession: True``; available a few seconds after release)."""
178
+ meta = self._http.json("GET", f"/sessions/{id}/replay-url")
179
+ url = (meta or {}).get("url")
180
+ if not url:
181
+ raise BrowserbaseError("Solari returned no replay URL")
182
+ r = httpx.get(url, timeout=60)
183
+ if not r.is_success:
184
+ raise APIError(r.status_code, f"replay download failed: {r.status_code}")
185
+ data = r.content
186
+ if data[:2] == b"\x1f\x8b":
187
+ data = gzip.decompress(data)
188
+ return [json.loads(line) for line in data.decode("utf-8").split("\n") if line.strip()]
189
+
190
+
191
+ class _Sessions:
192
+ def __init__(self, bb: "Browserbase") -> None:
193
+ self._bb = bb
194
+ self.recording = _Recording(bb._http)
195
+ self.replays = _Unsupported("sessions.replays", "Solari records DOM events, not video; use sessions.recording.retrieve")
196
+ self.logs = _Unsupported("sessions.logs", "session command logs are not available")
197
+ self.downloads = _Unsupported("sessions.downloads", "file downloads cannot be retrieved through the API")
198
+ self.uploads = _Unsupported("sessions.uploads", "use your automation client's set_input_files")
199
+
200
+ def create(
201
+ self,
202
+ *,
203
+ project_id: Optional[str] = None,
204
+ extension_id: Optional[str] = None,
205
+ browser_settings: Optional[Dict[str, Any]] = None,
206
+ timeout: Optional[int] = None,
207
+ keep_alive: Optional[bool] = None,
208
+ proxies: Any = None,
209
+ proxy_settings: Optional[Dict[str, Any]] = None,
210
+ region: Optional[str] = None,
211
+ user_metadata: Optional[Dict[str, Any]] = None,
212
+ ) -> SessionCreateResponse:
213
+ m = map_create_params(
214
+ extension_id=extension_id, browser_settings=browser_settings, timeout=timeout, keep_alive=keep_alive,
215
+ proxies=proxies, proxy_settings=proxy_settings, region=region, user_metadata=user_metadata, strict=self._bb._strict,
216
+ )
217
+ for w in m.warnings:
218
+ self._bb._warn(w)
219
+ data = self._bb._http.json("POST", "/sessions", m.body or None, idempotency_key=str(uuid.uuid4()))
220
+ if not data or not data.get("sessionId") or not data.get("cdpEndpoint"):
221
+ raise BrowserbaseError(f"Solari returned an unexpected session response: {data!r}")
222
+ if m.persist_profile_id:
223
+ self._bb._persist[data["sessionId"]] = m.persist_profile_id
224
+ ctx = ((browser_settings or {}).get("context") or {}).get("id")
225
+ base = _session(
226
+ {"id": data["sessionId"], "createdAt": None, "expiresAt": data.get("expiresAt")},
227
+ status="RUNNING", keep_alive=keep_alive is True, context_id=ctx,
228
+ )
229
+ out = SessionCreateResponse(**base.__dict__, connect_url=data["cdpEndpoint"])
230
+ if ctx:
231
+ state = data.get("storageState")
232
+ url = (data.get("storageStateUrl") or {}).get("url")
233
+ if state is None and url:
234
+ try:
235
+ r = self._bb._http.client.get(url, timeout=15)
236
+ state = r.json() if r.is_success else None
237
+ except (httpx.HTTPError, ValueError):
238
+ state = None
239
+ if state is None:
240
+ self._bb._warn("could not download the saved context; the session starts without it")
241
+ out.storage_state = state
242
+ return out
243
+
244
+ def retrieve(self, id: str) -> SessionRetrieveResponse:
245
+ row = self._bb._http.json("GET", f"/sessions/{id}") or {}
246
+ row.setdefault("id", id)
247
+ return SessionCreateResponse(**_session(row).__dict__, connect_url=row.get("cdpEndpoint") or "")
248
+
249
+ def list(self, *, status: Optional[str] = None, q: Optional[str] = None) -> List[Session]:
250
+ if q:
251
+ raise SolariCompatUnsupported("sessions.list q (user_metadata query)", "metadata is not stored")
252
+ data = self._bb._http.json("GET", "/sessions") or {}
253
+ out = [_session(r) for r in data.get("sessions", [])]
254
+ return [s for s in out if s.status == status] if status else out
255
+
256
+ def update(self, id: str, *, status: str, project_id: Optional[str] = None) -> Session:
257
+ """``status="REQUEST_RELEASE"`` releases the session (saving the context first when it was created with persist)."""
258
+ if status != "REQUEST_RELEASE":
259
+ raise SolariCompatUnsupported(f"sessions.update status {status}", "only REQUEST_RELEASE exists")
260
+ profile_id = self._bb._persist.get(id)
261
+ if profile_id:
262
+ self._bb._http.json("POST", f"/sessions/{id}/save-profile", {"profileId": profile_id})
263
+ self._bb._persist.pop(id, None)
264
+ self._bb._http.json("DELETE", f"/sessions/{id}")
265
+ return _session({"id": id, "status": "released"}, status="COMPLETED")
266
+
267
+ def debug(self, id: str, *, expires_in: Optional[int] = None) -> SessionLiveURLs:
268
+ """Live-view links. Both URLs are the same view-only share link; ``pages`` is empty."""
269
+ share = self._bb._http.json("POST", f"/sessions/{id}/shares", {} if expires_in is None else {"ttlSeconds": expires_in})
270
+ row = self._bb._http.json("GET", f"/sessions/{id}") or {}
271
+ return SessionLiveURLs(share["url"], share["url"], row.get("cdpEndpoint") or "", [])
272
+
273
+
274
+ class _Contexts:
275
+ def __init__(self, http: _Http) -> None:
276
+ self._http = http
277
+
278
+ def create(self, *, project_id: Optional[str] = None, name: Optional[str] = None) -> ContextCreateResponse:
279
+ """A Context is a Solari profile (cookies + localStorage)."""
280
+ p = self._http.json("POST", "/profiles", {"name": name or f"context-{uuid.uuid4().hex[:8]}"})
281
+ return ContextCreateResponse(id=p["id"])
282
+
283
+ def retrieve(self, id: str) -> Context:
284
+ for p in self._http.json("GET", "/profiles") or []:
285
+ if p.get("id") == id:
286
+ return Context(id=p["id"], name=p.get("name"), created_at=p.get("createdAt"))
287
+ raise APIError(404, f"context {id} not found", "NotFound")
288
+
289
+ def update(self, *_a: Any, **_k: Any) -> Any:
290
+ raise SolariCompatUnsupported("contexts.update", "contexts are saved when a persist session is released")
291
+
292
+ def delete(self, id: str) -> None:
293
+ self._http.json("DELETE", f"/profiles/{id}")
294
+
295
+
296
+ class Browserbase:
297
+ """Browserbase-shaped client for Solari Browser. ``connect_url`` is a raw CDP websocket URL."""
298
+
299
+ def __init__(
300
+ self,
301
+ *,
302
+ api_key: Optional[str] = None,
303
+ base_url: Optional[str] = None,
304
+ strict: bool = False,
305
+ on_warning: Optional[Callable[[str], None]] = None,
306
+ max_retries: int = 2,
307
+ timeout: float = 60.0,
308
+ transport: Optional[httpx.BaseTransport] = None,
309
+ ) -> None:
310
+ self._warn = on_warning or _default_warn
311
+ self._strict = strict
312
+ key = _resolve_api_key(api_key, self._warn)
313
+ self._http = _Http(key, base_url or os.environ.get("SOLARI_BASE_URL") or DEFAULT_BASE_URL, timeout, max_retries, transport)
314
+ self._persist: Dict[str, str] = {} # session id -> profile to save on release (in-process only)
315
+ self._api_key = key
316
+ self.sessions = _Sessions(self)
317
+ self.contexts = _Contexts(self._http)
318
+ self.extensions = _Unsupported("extensions", "browser extensions cannot be loaded")
319
+ self.projects = _Unsupported("projects", "Solari keys are organisation-scoped; there are no projects")
320
+ self.agents = _Unsupported("agents", "Browserbase Agents is a separate product")
321
+ self.fetch_api = _Unsupported("fetch_api", "Browserbase Fetch is a separate product")
322
+ self.search = _Unsupported("search", "Browserbase Search is a separate product")
323
+
324
+ @property
325
+ def api_key(self) -> str:
326
+ return self._api_key
327
+
328
+ def __repr__(self) -> str:
329
+ return f"Browserbase(base_url={self._http.base_url!r})"
330
+
331
+ def close(self) -> None:
332
+ self._http.client.close()
333
+
334
+ def __enter__(self) -> "Browserbase":
335
+ return self
336
+
337
+ def __exit__(self, *_a: Any) -> None:
338
+ self.close()
339
+
340
+
341
+ class _AsyncProxy:
342
+ def __init__(self, target: Any) -> None:
343
+ self._t = target
344
+
345
+ def __getattr__(self, name: str) -> Any:
346
+ v = getattr(self._t, name)
347
+ if callable(v):
348
+ async def _call(*a: Any, **k: Any) -> Any:
349
+ return await asyncio.to_thread(v, *a, **k)
350
+
351
+ return _call
352
+ if v.__class__.__module__.startswith("solari_browserbase") and not hasattr(v, "__dataclass_fields__"):
353
+ return _AsyncProxy(v)
354
+ return v
355
+
356
+
357
+ class AsyncBrowserbase:
358
+ """Async twin: every call runs the sync client in a worker thread (no extra dependencies)."""
359
+
360
+ @property
361
+ def api_key(self) -> str:
362
+ return self._sync.api_key
363
+
364
+ def __repr__(self) -> str:
365
+ return repr(self._sync).replace("Browserbase(", "AsyncBrowserbase(", 1)
366
+
367
+ def __init__(self, **kwargs: Any) -> None:
368
+ self._sync = Browserbase(**kwargs)
369
+ self.sessions = _AsyncProxy(self._sync.sessions)
370
+ self.contexts = _AsyncProxy(self._sync.contexts)
371
+ self.extensions = self._sync.extensions
372
+ self.projects = self._sync.projects
373
+ self.agents = self._sync.agents
374
+ self.fetch_api = self._sync.fetch_api
375
+ self.search = self._sync.search
376
+
377
+ async def close(self) -> None:
378
+ await asyncio.to_thread(self._sync.close)
@@ -0,0 +1,27 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+
6
+ class BrowserbaseError(Exception):
7
+ """Base class for everything this package raises."""
8
+
9
+
10
+ class APIError(BrowserbaseError):
11
+ """The gateway answered with a non-2xx status. ``code`` is Solari's machine-readable error code when present."""
12
+
13
+ def __init__(self, status: int, message: str, code: Optional[str] = None, body: Any = None) -> None:
14
+ super().__init__(message)
15
+ self.status = status
16
+ self.status_code = status
17
+ self.code = code
18
+ self.body = body
19
+
20
+
21
+ class SolariCompatUnsupported(BrowserbaseError):
22
+ """A Browserbase feature Solari Browser does not offer (see COMPAT.md)."""
23
+
24
+ def __init__(self, feature: str, hint: Optional[str] = None) -> None:
25
+ super().__init__(f"Not supported by Solari Browser: {feature}" + (f" — {hint}" if hint else ""))
26
+ self.feature = feature
27
+ self.hint = hint
@@ -0,0 +1,128 @@
1
+ """Browserbase ``sessions.create`` params -> Solari ``POST /sessions`` body. Pure functions, no I/O."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ from .errors import SolariCompatUnsupported
8
+
9
+ _REGIONS = {"us-west-2", "us-west"}
10
+
11
+
12
+ @dataclass
13
+ class MappedCreate:
14
+ body: Dict[str, Any]
15
+ persist_profile_id: Optional[str] = None
16
+ warnings: List[str] = field(default_factory=list)
17
+
18
+
19
+ def _proxy_body(proxies: Any, ca_certs: Any) -> Any:
20
+ if ca_certs:
21
+ raise SolariCompatUnsupported("proxy_settings.caCertificates", "custom CA certificates are not supported")
22
+ if proxies is None or proxies is False:
23
+ return None
24
+ if proxies is True:
25
+ return {"country": "us"}
26
+ if not isinstance(proxies, (list, tuple)) or len(proxies) == 0:
27
+ return None
28
+ if len(proxies) > 1:
29
+ raise SolariCompatUnsupported(
30
+ "multiple proxies / domain routing rules",
31
+ "pass at most one managed proxy; per-domain routing is not supported",
32
+ )
33
+ p = proxies[0]
34
+ if p.get("domainPattern"):
35
+ raise SolariCompatUnsupported("proxies[].domainPattern", "per-domain proxy routing is not supported")
36
+ t = p.get("type")
37
+ if t == "external":
38
+ raise SolariCompatUnsupported(
39
+ "external proxy", "bring-your-own proxy servers are not supported; use a managed proxy"
40
+ )
41
+ if t == "none":
42
+ return None # no proxy, same as not asking for one
43
+ if t != "browserbase":
44
+ raise SolariCompatUnsupported(f"proxy type {t!r}")
45
+ g = p.get("geolocation")
46
+ if not g:
47
+ return {"country": "us"}
48
+ out: Dict[str, Any] = {"country": str(g["country"]).lower()}
49
+ if g.get("state"):
50
+ out["state"] = str(g["state"]).lower()
51
+ if g.get("city"):
52
+ out["city"] = g["city"]
53
+ return out
54
+
55
+
56
+ def map_create_params(
57
+ *,
58
+ extension_id: Optional[str] = None,
59
+ browser_settings: Optional[Dict[str, Any]] = None,
60
+ timeout: Optional[int] = None,
61
+ keep_alive: Optional[bool] = None,
62
+ proxies: Any = None,
63
+ proxy_settings: Optional[Dict[str, Any]] = None,
64
+ region: Optional[str] = None,
65
+ user_metadata: Optional[Dict[str, Any]] = None,
66
+ strict: bool = False,
67
+ ) -> MappedCreate:
68
+ bs = browser_settings or {}
69
+ warnings: List[str] = []
70
+
71
+ def soft(feature: str, why: str) -> None:
72
+ if strict:
73
+ raise SolariCompatUnsupported(feature, why)
74
+ warnings.append(f"{feature}: {why}")
75
+
76
+ # hard gaps
77
+ if extension_id or bs.get("extensionId"):
78
+ raise SolariCompatUnsupported("extensions", "browser extensions cannot be loaded")
79
+ if bs.get("ignoreCertificateErrors") is True:
80
+ raise SolariCompatUnsupported("browserSettings.ignoreCertificateErrors")
81
+ if bs.get("captchaImageSelector") or bs.get("captchaInputSelector"):
82
+ raise SolariCompatUnsupported("custom CAPTCHA selectors")
83
+ if bs.get("os") in ("mobile", "tablet"):
84
+ raise SolariCompatUnsupported(f"browserSettings.os={bs['os']}", "only desktop browsers")
85
+ if region is not None and region not in _REGIONS:
86
+ raise SolariCompatUnsupported(f"region {region}", "Solari Browser runs in one region (us-west)")
87
+
88
+ # soft gaps
89
+ if timeout is not None:
90
+ soft("timeout", "sessions end at your plan's maximum session length, not at this value; call sessions.update to release earlier")
91
+ if keep_alive is True:
92
+ soft("keep_alive", "a disconnected session is released about 45 s after the socket closes; reconnect inside that window or hold the connection")
93
+ if user_metadata is not None:
94
+ soft("user_metadata", "metadata is not stored or queryable")
95
+ if bs.get("viewport") is not None:
96
+ soft("browserSettings.viewport", "set the viewport from your automation client (page.set_viewport_size)")
97
+ if bs.get("blockAds") is True:
98
+ soft("browserSettings.blockAds", "ad blocking is not available")
99
+ if bs.get("logSession") is True:
100
+ soft("browserSettings.logSession", "session command logs are not available")
101
+ if bs.get("os") in ("windows", "mac", "linux"):
102
+ soft("browserSettings.os", "the operating system profile cannot be chosen")
103
+
104
+ proxy = _proxy_body(proxies, (proxy_settings or {}).get("caCertificates"))
105
+ allowed = bs.get("allowedDomains") or None
106
+ captcha = bs.get("solveCaptchas") is True
107
+ stealth = bool(bs.get("verified") is True or bs.get("advancedStealth") is True or captcha or proxy is not None or allowed)
108
+
109
+ body: Dict[str, Any] = {}
110
+ if stealth:
111
+ body["stealth"] = True
112
+ if captcha:
113
+ body["captcha"] = True
114
+ if bs.get("recordSession") is True:
115
+ body["recording"] = True
116
+ if proxy is not None:
117
+ body["proxy"] = proxy
118
+ if allowed:
119
+ body["allowedDomains"] = list(allowed)
120
+ ctx = bs.get("context") or {}
121
+ if ctx.get("id"):
122
+ body["profileId"] = ctx["id"]
123
+ persist = ctx["id"] if ctx.get("id") and ctx.get("persist") is True else None
124
+ return MappedCreate(body=body, persist_profile_id=persist, warnings=warnings)
125
+
126
+
127
+ def map_status(s: Optional[str]) -> str:
128
+ return {"active": "RUNNING", "released": "COMPLETED", "expired": "TIMED_OUT"}.get(s or "", "ERROR")
@@ -0,0 +1,44 @@
1
+ Metadata-Version: 2.5
2
+ Name: solari-browserbase-compat
3
+ Version: 0.1.0
4
+ Summary: Browserbase-style Python SDK surface for Solari Browser: sessions, connect_url (CDP), contexts, live view, replay events.
5
+ Project-URL: Homepage, https://getsolari.com
6
+ Author: Solari
7
+ License: MIT
8
+ License-File: LICENSE
9
+ Keywords: browser,browserbase,cdp,compat,playwright,solari
10
+ Requires-Python: >=3.9
11
+ Requires-Dist: httpx>=0.24
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-browserbase-compat
18
+
19
+ A Browserbase-shaped Python SDK for [Solari Browser](https://getsolari.com): change the import and the key, keep your code.
20
+
21
+ ```bash
22
+ pip install solari-browserbase-compat
23
+ ```
24
+
25
+ ```python
26
+ import os
27
+ from solari_browserbase import Browserbase
28
+ from playwright.sync_api import sync_playwright
29
+
30
+ bb = Browserbase(api_key=os.environ["SOLARI_API_KEY"]) # slr_… key
31
+ session = bb.sessions.create()
32
+ with sync_playwright() as p:
33
+ browser = p.chromium.connect_over_cdp(session.connect_url)
34
+ page = browser.contexts[0].pages[0] if browser.contexts[0].pages else browser.contexts[0].new_page()
35
+ page.goto("https://example.com")
36
+ browser.close()
37
+ bb.sessions.update(session.id, status="REQUEST_RELEASE")
38
+ ```
39
+
40
+ Sessions, contexts, live view and recording are supported; extensions, downloads/uploads APIs, video replays,
41
+ bring-your-own proxies and Selenium raise `SolariCompatUnsupported`. See [COMPAT.md](./COMPAT.md) for the full list
42
+ and every difference. Python 3.9+. No Playwright version pin is needed: `connect_url` is a plain CDP endpoint.
43
+
44
+ Licence: MIT.
@@ -0,0 +1,8 @@
1
+ solari_browserbase/__init__.py,sha256=XPBOmaislUlruGe64vIJXK4MZLMmUsTCWaDkg4aiKBA,766
2
+ solari_browserbase/client.py,sha256=PgDwqA-d7geG9WIOwaIf2j0pBNDM4BH_Y1e1f72v_Xw,15654
3
+ solari_browserbase/errors.py,sha256=bzma6ijnhafgweOlDDcH05OvJBZldPRuE-R0Cm3ECkE,937
4
+ solari_browserbase/mapping.py,sha256=RA_KKzd9YkAsWOWE18SXzCvbsYzIMUwSAqDW8RyFJDw,5244
5
+ solari_browserbase_compat-0.1.0.dist-info/METADATA,sha256=eoCsNGWGHtjVolto-w2BUPMO1N8nWYL-b9yHhu1C6R8,1633
6
+ solari_browserbase_compat-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
7
+ solari_browserbase_compat-0.1.0.dist-info/licenses/LICENSE,sha256=k0WAH-cVo7vh3feyXnpOAySy92HrdreySyQa9JbRCdU,1063
8
+ solari_browserbase_compat-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Solari
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.