ysbrowser-sdk 0.11.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,20 @@
1
+ """Async context configuration for YSbrowser; Page and Locator remain native."""
2
+
3
+ from .browser import YSBrowser, YSContext
4
+ from .errors import SDKError, ConfigurationError, CompatibilityError, FingerprintLockedError, ProxyCheckError
5
+ from .configuration import ContextConfig
6
+ from .models import BrowserCapabilities, ProxyHealthResult, Direct, FingerprintProfile, ProxyCheck, ProxyConfig, ProxyStatus
7
+
8
+ __version__ = "0.11.0"
9
+ __all__ = [
10
+ "YSBrowser", "YSContext", "ContextConfig", "BrowserCapabilities", "ProxyHealthResult", "Direct", "FingerprintProfile", "ProxyCheck", "ProxyConfig",
11
+ "ProxyStatus", "SDKError", "ConfigurationError", "CompatibilityError",
12
+ "FingerprintLockedError", "ProxyCheckError", "__version__",
13
+ ]
14
+
15
+ from .mouse import AsyncTrajectoryMouse as TrajectoryMouse, MotionExecution
16
+ from .motion import TrajectoryModel, MotionPlan, MotionRequest
17
+ __all__ += ["TrajectoryMouse", "TrajectoryModel", "MotionPlan", "MotionRequest", "MotionExecution"]
18
+
19
+ from .options import ContextOptions
20
+ __all__ += ["ContextOptions"]
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,34 @@
1
+ """Cancellation-aware ownership transfer for native resources."""
2
+ import asyncio
3
+
4
+
5
+ async def _settle(operation):
6
+ task = asyncio.ensure_future(operation)
7
+ cancelled = False
8
+ while not task.done():
9
+ try:
10
+ await asyncio.shield(task)
11
+ except asyncio.CancelledError:
12
+ if task.cancelled():
13
+ raise
14
+ cancelled = True
15
+ except BaseException:
16
+ break
17
+ return task.result(), cancelled
18
+
19
+
20
+ async def complete(operation):
21
+ """Finish cleanup despite repeated caller cancellation, then propagate it."""
22
+ result, cancelled = await _settle(operation)
23
+ if cancelled:
24
+ raise asyncio.CancelledError()
25
+ return result
26
+
27
+
28
+ async def acquire(operation, cleanup):
29
+ """Never abandon a resource whose creation has reached the native driver."""
30
+ result, cancelled = await _settle(operation)
31
+ if cancelled:
32
+ await complete(cleanup(result))
33
+ raise asyncio.CancelledError()
34
+ return result
@@ -0,0 +1,9 @@
1
+ """Sync equivalents: native calls complete before passing their result here."""
2
+
3
+
4
+ def complete(result):
5
+ return result
6
+
7
+
8
+ def acquire(result, cleanup):
9
+ return result
@@ -0,0 +1,14 @@
1
+ """Native asynchronous Patchright API with YSbrowser configuration."""
2
+ from .browser import YSBrowser, YSContext
3
+ from .configuration import ContextConfig
4
+ from .models import BrowserCapabilities, ProxyHealthResult, Direct, FingerprintProfile, ProxyCheck, ProxyConfig, ProxyStatus
5
+ from .errors import SDKError, ConfigurationError, CompatibilityError, FingerprintLockedError, ProxyCheckError
6
+
7
+ __all__ = ["YSBrowser", "YSContext", "ContextConfig", "BrowserCapabilities", "ProxyHealthResult", "Direct", "FingerprintProfile", "ProxyCheck", "ProxyConfig", "ProxyStatus", "SDKError", "ConfigurationError", "CompatibilityError", "FingerprintLockedError", "ProxyCheckError"]
8
+
9
+ from .mouse import AsyncTrajectoryMouse as TrajectoryMouse, MotionExecution
10
+ from .motion import TrajectoryModel, MotionPlan, MotionRequest
11
+ __all__ += ["TrajectoryMouse", "TrajectoryModel", "MotionPlan", "MotionRequest", "MotionExecution"]
12
+
13
+ from .options import ContextOptions
14
+ __all__ += ["ContextOptions"]
@@ -0,0 +1,561 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import copy
5
+ import inspect
6
+ import time
7
+ from collections import deque
8
+ import re
9
+ import socket
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from patchright.async_api import Browser, BrowserContext, CDPSession, Page, async_playwright
14
+
15
+ from .errors import CompatibilityError, ConfigurationError, FingerprintLockedError, ProxyCheckError, SDKError
16
+ from .diagnostics import DiagnosticsMixin
17
+ from .options import ContextOptions
18
+ from .configuration import ContextConfig, write_json
19
+ from .models import BrowserCapabilities, ProxyHealthResult, safe_url, Direct, FingerprintProfile, ProxyCheck, ProxyConfig, ProxyStatus
20
+ from .mouse import AsyncTrajectoryMouse as TrajectoryMouse, MotionExecution
21
+ from .motion import TrajectoryModel, MotionPlan, MotionRequest
22
+ from ._async_runtime import acquire, complete
23
+ from .runtime import launch_policy, runtime_info
24
+ from .installer import get_browser_path
25
+
26
+
27
+ class YSContext(DiagnosticsMixin):
28
+ """A configured context; raw is the original Patchright BrowserContext."""
29
+
30
+ @property
31
+ def options(self) -> ContextOptions:
32
+ """Return a detached copy of the options used to create this Context."""
33
+ return copy.deepcopy(self._options)
34
+
35
+ def __init__(self, owner: YSBrowser, raw: BrowserContext, context_id: str, name: str):
36
+ self._owner = owner
37
+ self.raw = raw
38
+ self.context_id = context_id
39
+ self.name = name
40
+ self._closed = False
41
+ self._gate = asyncio.Lock()
42
+ self._close_gate = asyncio.Lock()
43
+ self._fingerprint = FingerprintProfile()
44
+ self._proxy: ProxyConfig | Direct = Direct()
45
+ self._proxy_generation: str | None = None
46
+ self._no_viewport = True
47
+ self._options = ContextOptions()
48
+ self._recent_errors: deque[dict[str, Any]] = deque(maxlen=20)
49
+ self.raw.on("close", self._on_closed)
50
+ self.raw.on("requestfailed", self._on_request_failed)
51
+ self.raw.on("response", self._on_response)
52
+ self._init_diagnostics()
53
+
54
+ def _on_closed(self, *_: Any) -> None:
55
+ self._closed = True
56
+ self._diagnostic_trace = False
57
+ self._detach_diagnostics()
58
+ if self._owner._contexts.get(self.name) is self:
59
+ self._owner._contexts.pop(self.name, None)
60
+
61
+ def _on_request_failed(self, request: Any) -> None:
62
+ try:
63
+ if request.is_navigation_request():
64
+ match = re.search(r"net::ERR_[A-Z_]+", request.failure or "")
65
+ self._recent_errors.append({"kind": "navigation_network_error", "at": time.time() * 1000,
66
+ "origin": safe_url(request.url), "network_error": match.group() if match else "unknown"})
67
+ except Exception:
68
+ pass
69
+
70
+ def _on_response(self, response: Any) -> None:
71
+ try:
72
+ if response.status >= 400 and response.request.is_navigation_request():
73
+ self._recent_errors.append({"kind": "navigation_http_error", "at": time.time() * 1000,
74
+ "origin": safe_url(response.url), "http_status": response.status})
75
+ except Exception:
76
+ pass
77
+
78
+
79
+ def _ensure_open(self) -> None:
80
+ if self._owner._unexpected_disconnect:
81
+ raise SDKError("BROWSER_DISCONNECTED", "context", "Browser connection was lost; create a new browser and restore saved configuration/state", context_id=self.context_id)
82
+ if self._closed:
83
+ raise SDKError("CONTEXT_CLOSED", "context", "Context has been closed", context_id=self.context_id)
84
+
85
+ async def _send(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
86
+ self._ensure_open()
87
+ assert self._owner._cdp is not None
88
+ return await self._owner._cdp.send(method, {"browserContextId": self.context_id, **(params or {})})
89
+
90
+ async def fingerprint_status(self) -> dict[str, Any]:
91
+ try:
92
+ return (await self._send("Browser.getFingerprintProfile"))["status"]
93
+ except SDKError:
94
+ raise
95
+ except Exception as exc:
96
+ raise CompatibilityError("FINGERPRINT_STATUS_FAILED", "fingerprint.status",
97
+ "YSbrowser fingerprint CDP API is unavailable or failed", context_id=self.context_id,
98
+ original_error=exc) from None
99
+
100
+ async def _apply_fingerprint(self, profile: FingerprintProfile) -> None:
101
+ payload = profile.to_cdp()
102
+ capabilities = self._owner.capabilities
103
+ unknown = sorted(set(payload) - set(capabilities.fingerprint_fields))
104
+ if unknown:
105
+ raise CompatibilityError("UNSUPPORTED_FINGERPRINT_FIELDS", "fingerprint.set", "Browser does not support requested fingerprint fields", details={"fields": unknown})
106
+ if profile.server_gpu_profile is not None and (not capabilities.context_gpu_available or profile.server_gpu_profile not in capabilities.gpu_profiles):
107
+ raise ConfigurationError("Requested GPU profile is unsupported or Context GPU mode is unavailable")
108
+ status = await self.fingerprint_status()
109
+ if status["locked"]:
110
+ raise FingerprintLockedError("FINGERPRINT_LOCKED", "fingerprint.set",
111
+ "Create a new context; this context has already started a renderer", context_id=self.context_id)
112
+ try:
113
+ response = await self._send("Browser.setFingerprintProfile", {"profile": payload})
114
+ if profile.server_gpu_profile is not None and response.get("status", {}).get("profile", {}).get("serverGpuProfile") != profile.server_gpu_profile:
115
+ raise CompatibilityError("CONTEXT_GPU_UNSUPPORTED", "fingerprint.set",
116
+ "This browser does not acknowledge per-context GPU profiles; install the context GPU patch",
117
+ context_id=self.context_id)
118
+ self._fingerprint = profile
119
+ except CompatibilityError:
120
+ raise
121
+ except Exception as exc:
122
+ # Raw contexts may be manipulated by another client, so also inspect lock after failure.
123
+ status = await self.fingerprint_status()
124
+ if status["locked"]:
125
+ raise FingerprintLockedError("FINGERPRINT_LOCKED", "fingerprint.set", "A renderer started before configuration completed",
126
+ context_id=self.context_id, original_error=exc) from None
127
+ raise SDKError("FINGERPRINT_SET_FAILED", "fingerprint.set", "Browser rejected the profile; check supported values",
128
+ context_id=self.context_id, original_error=exc) from None
129
+
130
+ async def set_fingerprint(self, profile: FingerprintProfile) -> None:
131
+ async with self._gate:
132
+ await self._apply_fingerprint(profile)
133
+
134
+ async def proxy_status(self) -> ProxyStatus:
135
+ try:
136
+ return ProxyStatus.from_cdp((await self._send("Browser.getProxyStatus"))["status"])
137
+ except SDKError:
138
+ raise
139
+ except Exception as exc:
140
+ raise CompatibilityError("PROXY_STATUS_FAILED", "proxy.status", "YSbrowser proxy CDP API is unavailable or failed",
141
+ context_id=self.context_id, original_error=exc) from None
142
+
143
+ async def _apply_proxy(self, proxy: ProxyConfig | Direct, check: ProxyCheck | None,
144
+ mode: str | None = None, drain_timeout_ms: int | None = None) -> ProxyStatus:
145
+ if not isinstance(proxy, (ProxyConfig, Direct)) or (check is not None and not isinstance(check, ProxyCheck)):
146
+ raise ConfigurationError("proxy must be ProxyConfig or Direct; check must be ProxyCheck")
147
+ if self._options.client_certificates and not isinstance(proxy, Direct):
148
+ raise ConfigurationError('Client-certificate Contexts currently support Direct only; the native certificate tunnel bypasses runtime proxy changes')
149
+ if mode not in (None, "drain", "immediate"):
150
+ raise ConfigurationError("mode must be 'drain', 'immediate', or None (legacy)")
151
+ if drain_timeout_ms is not None and (type(drain_timeout_ms) is not int or not 1 <= drain_timeout_ms <= 60000):
152
+ raise ConfigurationError("drain_timeout_ms must be an integer between 1 and 60000")
153
+ if mode is None and drain_timeout_ms is not None:
154
+ raise ConfigurationError("drain_timeout_ms requires mode")
155
+ if mode is not None and not self._owner.capabilities.supports("proxySwitchBarrier"):
156
+ raise CompatibilityError("PROXY_SWITCH_UNSUPPORTED", "proxy.switch", "Install the browser proxy switch barrier patch", context_id=self.context_id)
157
+ payload = proxy.to_cdp()
158
+ if mode is not None:
159
+ payload["switchMode"] = mode
160
+ if drain_timeout_ms is not None:
161
+ payload["drainTimeoutMs"] = drain_timeout_ms
162
+ if check is not None:
163
+ payload.update(check.to_cdp())
164
+ try:
165
+ response = await self._send("Browser.setProxy", payload)
166
+ except Exception as exc:
167
+ try:
168
+ status = (await self.proxy_status()).to_dict()
169
+ except Exception:
170
+ status = {"check_state": "unavailable"}
171
+ # A failed command has no authoritative operation revision. Another
172
+ # CDP client may have changed the route before the follow-up query.
173
+ # Never associate unknown credentials with that observed revision.
174
+ switch_code = next((code for code in (
175
+ "PROXY_DRAIN_TIMEOUT", "PROXY_SWITCH_IN_PROGRESS",
176
+ "PROXY_SWITCH_CONTEXT_LOST", "PROXY_SWITCH_CANCELLED",
177
+ "PROXY_SWITCH_LEASE_EXPIRED", "PROXY_SWITCH_MULTIPLE_PARTITIONS_UNSUPPORTED",
178
+ "PROXY_SWITCH_BUSY_OR_INVALID") if code in str(exc)), None)
179
+ # A drain timeout or rejected concurrent operation does not apply a
180
+ # proxy. Preserve exportability only if the original revision remains.
181
+ unchanged = switch_code in ("PROXY_DRAIN_TIMEOUT", "PROXY_SWITCH_IN_PROGRESS", "PROXY_SWITCH_MULTIPLE_PARTITIONS_UNSUPPORTED") and status.get("generation") == self._proxy_generation
182
+ if not unchanged:
183
+ self._proxy_generation = None
184
+ if switch_code:
185
+ raise SDKError(switch_code, "proxy.switch", "Guarded proxy switch did not complete; inspect proxy_status before continuing", context_id=self.context_id,
186
+ details={"proxy_status": status, "mode": mode}, original_error=exc) from None
187
+ error_class = ProxyCheckError if check is not None else SDKError
188
+ raise error_class("PROXY_CHECK_FAILED" if check is not None else "PROXY_SET_FAILED", "proxy.set",
189
+ "Proxy operation failed; inspect status for rollback outcome", context_id=self.context_id,
190
+ details={"proxy_status": status}, original_error=exc) from None
191
+ observed = ProxyStatus.from_cdp(response["status"])
192
+ self._proxy = proxy
193
+ self._proxy_generation = observed.generation
194
+ return observed
195
+
196
+ async def set_proxy(self, proxy: ProxyConfig | Direct, *, check: ProxyCheck | None = None,
197
+ mode: str | None = None, drain_timeout_ms: int | None = None) -> ProxyStatus:
198
+ """Switch routes; explicit drain/immediate modes pause new native requests.
199
+
200
+ None preserves the legacy API. A drain timeout keeps the old proxy.
201
+ Long-lived streams count as active work; they can make drain time out.
202
+ Cancelling this Python await does not undo an already-issued CDP command.
203
+ """
204
+ async with self._gate:
205
+ return await self._apply_proxy(proxy, check, mode, drain_timeout_ms)
206
+
207
+ async def check_proxy(self, check_url: str, *, expected_exit_ip: str | None = None,
208
+ timeout_ms: int = 10000) -> ProxyHealthResult:
209
+ check = ProxyCheck(check_url, expected_exit_ip=expected_exit_ip, timeout_ms=timeout_ms)
210
+ payload = check.to_cdp()
211
+ payload.pop("rollbackOnFailure")
212
+ # Intentionally no mutation lock: native generation/check IDs protect late results.
213
+ try:
214
+ result = ProxyHealthResult.from_cdp((await self._send("Browser.checkProxy", payload))["result"])
215
+ except SDKError:
216
+ raise
217
+ except Exception as exc:
218
+ raise SDKError("PROXY_CHECK_COMMAND_FAILED", "proxy.check", "Independent proxy check could not be completed by the protocol", context_id=self.context_id, original_error=exc) from None
219
+ return result
220
+
221
+ async def diagnostics(self) -> dict[str, Any]:
222
+ result: dict[str, Any] = {"context_id": self.context_id, "closed": self._closed,
223
+ "browser_connected": self._owner._browser is not None and self._owner._browser.is_connected(),
224
+ "versions": self._owner.diagnostics(), "recent_network_errors": copy.deepcopy(list(self._recent_errors))}
225
+ result['recent_page_events'] = [{k: v for k, v in e.items() if k != 'message'} for e in self._diagnostic_events]
226
+ result['diagnostics_collecting'] = self._diagnostic_enabled
227
+ if self._closed or self._owner._unexpected_disconnect:
228
+ return result
229
+ try:
230
+ fingerprint = await self.fingerprint_status()
231
+ result["fingerprint"] = {"locked": fingerprint["locked"],
232
+ "configured_fields": sorted(fingerprint["profile"]),
233
+ "server_gpu_profile": fingerprint["profile"].get("serverGpuProfile")}
234
+ result["proxy"] = (await self.proxy_status()).to_dict()
235
+ except SDKError as exc:
236
+ result["status_error"] = exc.to_dict()
237
+ return result
238
+
239
+ async def export_config(self, path: str | Path | None = None, *,
240
+ include_proxy_credentials: bool = False, include_sensitive_options: bool = False) -> dict[str, Any]:
241
+ async with self._gate:
242
+ self._ensure_open()
243
+ proxy = await self.proxy_status()
244
+ fingerprint = await self.fingerprint_status()
245
+ if proxy.generation != self._proxy_generation or fingerprint["profile"] != self._fingerprint.to_cdp():
246
+ raise SDKError("CONFIG_CHANGED_EXTERNALLY", "context.export", "Current configuration cannot be confirmed from this SDK Context; reapply it successfully before exporting", context_id=self.context_id)
247
+ config = ContextConfig(self._fingerprint, self._proxy, self._no_viewport, self._options)
248
+ data = config.to_dict(include_proxy_credentials=include_proxy_credentials, include_sensitive_options=include_sensitive_options)
249
+ if path is not None:
250
+ write_json(path, data)
251
+ return data
252
+
253
+ async def save_storage_state(self, path: str | Path | None = None, *, indexed_db: bool = True) -> dict[str, Any]:
254
+ async with self._gate:
255
+ self._ensure_open()
256
+ if type(indexed_db) is not bool:
257
+ raise ConfigurationError("indexed_db must be boolean")
258
+ try:
259
+ state = await self.raw.storage_state(indexed_db=indexed_db)
260
+ if path is not None:
261
+ write_json(path, state)
262
+ return state
263
+ except Exception as exc:
264
+ raise SDKError("STORAGE_SAVE_FAILED", "context.storage.save", "Could not export login storage state", context_id=self.context_id, original_error=exc) from None
265
+
266
+ async def restore_storage_state(self, state: dict[str, Any] | str | Path) -> None:
267
+ async with self._gate:
268
+ self._ensure_open()
269
+ if self.raw.pages:
270
+ raise SDKError("STORAGE_RESTORE_REQUIRES_EMPTY_CONTEXT", "context.storage.restore", "Restore before opening pages, preferably in a new Context", context_id=self.context_id)
271
+ try:
272
+ await self.raw.set_storage_state(state)
273
+ except Exception as exc:
274
+ raise SDKError("STORAGE_RESTORE_FAILED", "context.storage.restore", "Could not restore login storage state", context_id=self.context_id, original_error=exc) from None
275
+
276
+ def mouse(self, page: Page, *, model: TrajectoryModel | str | Path | None = None,
277
+ fallback: str = "error", viewport: tuple[float, float] | None = None, **model_options) -> TrajectoryMouse:
278
+ """Bind a trajectory mouse to a native Page owned by this Context.
279
+
280
+ Call synchronize once before moving. Adapters for the same Page share
281
+ position and serialization. Native mouse/Locator calls are not intercepted.
282
+ """
283
+ self._ensure_open()
284
+ if page.context is not self.raw:
285
+ raise ValueError("page must belong to this YSContext")
286
+ if page.is_closed():
287
+ raise ValueError("page is closed")
288
+ return TrajectoryMouse(page, model, fallback=fallback, viewport=viewport, **model_options)
289
+
290
+ async def new_page(self) -> Page:
291
+ async with self._gate:
292
+ self._ensure_open()
293
+ page = await self.raw.new_page()
294
+ await self._attach_diagnostic_page(page)
295
+ return page
296
+
297
+ async def close(self) -> None:
298
+ # Closing must interrupt a draining switch instead of waiting for its
299
+ # mutation lock/deadline. A separate lock preserves idempotent close.
300
+ async with self._close_gate:
301
+ if not self._closed:
302
+ self._closed = True
303
+ try:
304
+ await complete(self.raw.close())
305
+ finally:
306
+ self._on_closed()
307
+
308
+ async def __aenter__(self) -> YSContext:
309
+ self._ensure_open()
310
+ return self
311
+
312
+ async def __aexit__(self, *args: Any) -> None:
313
+ await self.close()
314
+
315
+
316
+ class YSBrowser:
317
+ def __init__(self, executable_path: str | Path | None = None, *, headless: bool = False,
318
+ server_gpu_profile: str | None = None, media_router: bool = True,
319
+ context_gpu: bool = True, server_gpu_backend: str = "warp",
320
+ default_proxy: ProxyConfig | Direct | None = None, args: list[str] | None = None,
321
+ cdp_port: int | None = None):
322
+ self.executable_path = str(Path(executable_path).resolve() if executable_path is not None else get_browser_path())
323
+ if not Path(self.executable_path).is_file():
324
+ raise ConfigurationError("executable_path must point to an existing YSbrowser executable")
325
+ if server_gpu_profile is not None and (not isinstance(server_gpu_profile, str) or not re.fullmatch(r"[a-zA-Z0-9_-]+", server_gpu_profile)):
326
+ raise ConfigurationError("server_gpu_profile must be a GPU profile identifier")
327
+ if default_proxy is not None and not isinstance(default_proxy, (ProxyConfig, Direct)):
328
+ raise ConfigurationError("default_proxy must be ProxyConfig or Direct")
329
+ if type(context_gpu) is not bool or server_gpu_backend not in ("warp", "swiftshader"):
330
+ raise ConfigurationError("context_gpu must be boolean; server_gpu_backend must be warp or swiftshader")
331
+ self.context_gpu = context_gpu
332
+ if cdp_port is not None and (type(cdp_port) is not int or not 0 <= cdp_port <= 65535):
333
+ raise ConfigurationError("cdp_port must be None or an integer from 0 to 65535")
334
+ self.cdp_port = cdp_port
335
+ self._cdp_port: int | None = None
336
+ self.server_gpu_backend = server_gpu_backend
337
+ self.headless = headless
338
+ self.server_gpu_profile = server_gpu_profile
339
+ self.media_router = media_router
340
+ self.default_proxy = default_proxy if default_proxy is not None else Direct()
341
+ self.args = list(args or [])
342
+ managed = ("--disable-features", "--enable-features", "--proxy-", "--user-data-dir", "--remote-debugging", "--server-gpu-")
343
+ if any(not isinstance(arg, str) or arg.startswith(managed) for arg in self.args):
344
+ raise ConfigurationError("args must not override SDK feature policy, proxy, profile directory, debugging transport or GPU profile")
345
+ self._pw: Any = None
346
+ self._browser: Browser | None = None
347
+ self._cdp: CDPSession | None = None
348
+ self._contexts: dict[str, YSContext] = {}
349
+ self._creation_lock = asyncio.Lock()
350
+ self._lifecycle_lock = asyncio.Lock()
351
+ self._info: dict[str, Any] = {}
352
+ self._counter = 0
353
+ self._closing = False
354
+ self._unexpected_disconnect = False
355
+ self._disconnect_handlers: list[Any] = []
356
+ self._capabilities: BrowserCapabilities | None = None
357
+
358
+ @property
359
+ def capabilities(self) -> BrowserCapabilities:
360
+ if self._capabilities is None:
361
+ raise SDKError("BROWSER_NOT_RUNNING", "capabilities", "Start the browser before reading its capabilities")
362
+ return self._capabilities
363
+
364
+ def get_context(self, name: str) -> YSContext:
365
+ try:
366
+ return self._contexts[name]
367
+ except KeyError:
368
+ raise ConfigurationError("No open Context with this name") from None
369
+
370
+ async def close_context(self, name: str) -> None:
371
+ await self.get_context(name).close()
372
+
373
+ def on_disconnected(self, callback: Any) -> None:
374
+ if not callable(callback) or inspect.iscoroutinefunction(callback):
375
+ raise ConfigurationError("Disconnect callback must be a synchronous callable receiving one event dictionary")
376
+ self._disconnect_handlers.append(callback)
377
+
378
+ def _on_disconnected(self, *_: Any) -> None:
379
+ self._unexpected_disconnect = not self._closing
380
+ event = {"unexpected": self._unexpected_disconnect, "at": time.time() * 1000,
381
+ "context_count": len(self._contexts)}
382
+ self._info["disconnect"] = event
383
+ for context in list(self._contexts.values()):
384
+ context._closed = True
385
+ for callback in tuple(self._disconnect_handlers):
386
+ try:
387
+ callback(dict(event))
388
+ except Exception as exc:
389
+ self._info["disconnect_callback_error"] = type(exc).__name__
390
+
391
+ @property
392
+ def raw(self) -> Browser:
393
+ if self._browser is None or not self._browser.is_connected():
394
+ raise SDKError("BROWSER_NOT_RUNNING", "browser", "Start the browser using async with or await start()")
395
+ return self._browser
396
+
397
+ @property
398
+ def contexts(self) -> tuple[YSContext, ...]:
399
+ return tuple(self._contexts.values())
400
+
401
+ @property
402
+ def cdp_endpoint(self) -> str | None:
403
+ """Optional loopback CDP endpoint for trusted integrations; None unless enabled."""
404
+ self.raw
405
+ return f"http://127.0.0.1:{self._cdp_port}" if self._cdp_port is not None else None
406
+
407
+ async def start(self) -> YSBrowser:
408
+ async with self._lifecycle_lock:
409
+ return await self._start_unlocked()
410
+
411
+ async def _start_unlocked(self) -> YSBrowser:
412
+ if self._browser is not None:
413
+ if not self._browser.is_connected():
414
+ raise SDKError("BROWSER_DISCONNECTED", "browser.start", "Close this instance before restarting it")
415
+ return self
416
+ self._info = runtime_info()
417
+ policy = launch_policy(self.media_router)
418
+ launch_args = ["--no-first-run", "--no-default-browser-check", *policy["args"], *self.args]
419
+ self._cdp_port = self.cdp_port
420
+ if self.cdp_port == 0:
421
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
422
+ probe.bind(("127.0.0.1", 0))
423
+ self._cdp_port = probe.getsockname()[1]
424
+ if self._cdp_port is not None:
425
+ launch_args.extend([f"--remote-debugging-port={self._cdp_port}", "--remote-debugging-address=127.0.0.1"])
426
+ if self.context_gpu:
427
+ launch_args.append("--server-gpu-contexts")
428
+ if self.context_gpu or self.server_gpu_profile is not None:
429
+ launch_args.append(f"--server-gpu-backend={self.server_gpu_backend}")
430
+ if self.server_gpu_profile is not None:
431
+ launch_args.append(f"--server-gpu-profile={self.server_gpu_profile}")
432
+ try:
433
+ self._pw = await acquire(async_playwright().start(), lambda pw: pw.stop())
434
+ self._browser = await acquire(self._pw.chromium.launch(executable_path=self.executable_path, headless=self.headless,
435
+ chromium_sandbox=True, args=launch_args,
436
+ ignore_default_args=policy["ignore_default_args"]), lambda browser: browser.close())
437
+ self._closing = False
438
+ self._unexpected_disconnect = False
439
+ self._browser.on("disconnected", self._on_disconnected)
440
+ self._cdp = await self._browser.new_browser_cdp_session()
441
+ try:
442
+ self._capabilities = BrowserCapabilities.from_cdp((await self._cdp.send("Browser.getCapabilities"))["capabilities"])
443
+ except Exception as exc:
444
+ raise CompatibilityError("BROWSER_CAPABILITIES_UNAVAILABLE", "browser.compatibility", "This SDK requires a browser with the capabilities/proxy-health patch", original_error=exc) from None
445
+ required = {"contextFingerprint", "contextProxy", "proxyCheck", "proxyGeneration"}
446
+ missing = sorted(required - set(self.capabilities.features))
447
+ if self.capabilities.protocol_version.split(".")[0] != "1" or missing:
448
+ raise CompatibilityError("BROWSER_CAPABILITIES_UNSUPPORTED", "browser.compatibility", "Browser protocol or capabilities are unsupported", details={"missing_features": missing})
449
+ self._info["capabilities"] = self.capabilities.to_dict()
450
+ self._info["browser"] = await self._cdp.send("Browser.getVersion")
451
+ self._info["context_gpu"] = self.context_gpu
452
+ self._info["server_gpu_backend"] = self.server_gpu_backend if self.context_gpu or self.server_gpu_profile is not None else None
453
+ self._info["media_router"] = self.media_router
454
+ self._info["server_gpu_profile"] = self.server_gpu_profile
455
+ self._closing = False
456
+ return self
457
+ except BaseException as exc:
458
+ await complete(self._close_unlocked())
459
+ if not isinstance(exc, Exception) or isinstance(exc, SDKError):
460
+ raise
461
+ raise SDKError("LAUNCH_FAILED", "browser.start", "Could not start the requested browser", original_error=exc) from None
462
+
463
+ async def new_context(self, *, name: str | None = None, fingerprint: FingerprintProfile | None = None,
464
+ proxy: ProxyConfig | Direct | None = None, check: ProxyCheck | None = None,
465
+ no_viewport: bool | None = None, config: ContextConfig | None = None,
466
+ options: ContextOptions | None = None,
467
+ storage_state: dict[str, Any] | str | Path | None = None) -> YSContext:
468
+ if config is not None and not isinstance(config, ContextConfig):
469
+ raise ConfigurationError("config must be ContextConfig")
470
+ selected_options = options if options is not None else (config.options if config else ContextOptions())
471
+ if not isinstance(selected_options, ContextOptions):
472
+ raise ConfigurationError('options must be ContextOptions')
473
+ selected_options = copy.deepcopy(selected_options)
474
+ native_options = selected_options.to_native()
475
+ if selected_options.viewport is not None and no_viewport is True:
476
+ raise ConfigurationError('viewport conflicts with no_viewport=True')
477
+ profile = fingerprint if fingerprint is not None else (config.fingerprint if config else FingerprintProfile())
478
+ selected_proxy = proxy if proxy is not None else (config.proxy if config else self.default_proxy)
479
+ if selected_options.client_certificates and not isinstance(selected_proxy, Direct):
480
+ raise ConfigurationError('Client-certificate Contexts currently support Direct only; proxy routing requires a separate adapter')
481
+ viewport = no_viewport if no_viewport is not None else (config.no_viewport if config else True)
482
+ if selected_options.viewport is not None and no_viewport is None:
483
+ viewport = False
484
+ if type(viewport) is not bool:
485
+ raise ConfigurationError("no_viewport must be boolean")
486
+ if viewport and selected_options.device_scale_factor is not None:
487
+ raise ConfigurationError('device_scale_factor requires options.viewport or no_viewport=False')
488
+ if not isinstance(profile, FingerprintProfile):
489
+ raise ConfigurationError("fingerprint must be FingerprintProfile")
490
+ profile.to_cdp()
491
+ if profile.server_gpu_profile is not None and not self.context_gpu and self.server_gpu_profile is None:
492
+ raise ConfigurationError("Context GPU profiles require context_gpu=True or a startup GPU profile")
493
+ async with self._lifecycle_lock, self._creation_lock:
494
+ if self._closing:
495
+ raise SDKError("BROWSER_CLOSING", "context.create", "Browser is closing")
496
+ browser = self.raw
497
+ assert self._cdp is not None
498
+ if name is None:
499
+ while name is None or name in self._contexts:
500
+ self._counter += 1
501
+ name = f"context-{self._counter}"
502
+ if not isinstance(name, str) or not name.strip() or name in self._contexts:
503
+ raise ConfigurationError("context name must be non-empty and unique among open contexts")
504
+ before = set((await self._cdp.send("Target.getBrowserContexts"))["browserContextIds"])
505
+ raw = await acquire(browser.new_context(no_viewport=viewport, **native_options), lambda context: context.close())
506
+ try:
507
+ after = set((await self._cdp.send("Target.getBrowserContexts"))["browserContextIds"])
508
+ ids = after - before
509
+ if len(ids) != 1:
510
+ raise SDKError("CONTEXT_ID_AMBIGUOUS", "context.create", "Cannot map context ID; avoid concurrent creation through raw or external CDP clients")
511
+ context = YSContext(self, raw, ids.pop(), name)
512
+ context._options = selected_options
513
+ await context._apply_fingerprint(profile)
514
+ await context._apply_proxy(selected_proxy, check)
515
+ context._no_viewport = viewport
516
+ if selected_options.default_timeout_ms is not None:
517
+ raw.set_default_timeout(selected_options.default_timeout_ms)
518
+ if selected_options.navigation_timeout_ms is not None:
519
+ raw.set_default_navigation_timeout(selected_options.navigation_timeout_ms)
520
+ # Native restore may create temporary renderers: configure fingerprint first.
521
+ if storage_state is not None:
522
+ await context.restore_storage_state(storage_state)
523
+ self._contexts[name] = context
524
+ return context
525
+ except BaseException:
526
+ await complete(raw.close())
527
+ raise
528
+
529
+ def diagnostics(self) -> dict[str, Any]:
530
+ # No custom argv, proxy credentials, seeds, URLs or user-supplied names.
531
+ return copy.deepcopy({**self._info, "context_count": len(self._contexts),
532
+ "connected": self._browser is not None and self._browser.is_connected(),
533
+ "contexts": [{"context_id": c.context_id} for c in self.contexts]})
534
+
535
+ async def close(self) -> None:
536
+ async with self._lifecycle_lock, self._creation_lock:
537
+ await complete(self._close_unlocked())
538
+
539
+ async def _close_unlocked(self) -> None:
540
+ self._closing = True
541
+ try:
542
+ if self._browser is not None:
543
+ await self._browser.close()
544
+ finally:
545
+ for context in list(self._contexts.values()):
546
+ context._closed = True
547
+ self._contexts.clear()
548
+ self._browser = None
549
+ self._cdp = None
550
+ self._capabilities = None
551
+ if self._pw is not None:
552
+ try:
553
+ await self._pw.stop()
554
+ finally:
555
+ self._pw = None
556
+
557
+ async def __aenter__(self) -> YSBrowser:
558
+ return await self.start()
559
+
560
+ async def __aexit__(self, *args: Any) -> None:
561
+ await self.close()