barebidi 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.
barebidi/__init__.py ADDED
@@ -0,0 +1,33 @@
1
+ from . import protocol
2
+ from .browser import Browser, find_firefox, start
3
+ from .connection import BidiError, BrowserClosedError, Connection
4
+ from .context import Context
5
+ from .dialogs import Dialog
6
+ from .downloads import Download
7
+ from .element import Element, ElementQuery
8
+ from .frame import Frame, JavaScriptError
9
+ from .network import ExpectedResponse, Intercept, InterceptedRequest, Request, Response
10
+ from .tab import Tab
11
+
12
+ __all__ = [
13
+ "BidiError",
14
+ "BrowserClosedError",
15
+ "Browser",
16
+ "Connection",
17
+ "Context",
18
+ "Dialog",
19
+ "Download",
20
+ "Element",
21
+ "ElementQuery",
22
+ "ExpectedResponse",
23
+ "Frame",
24
+ "Intercept",
25
+ "InterceptedRequest",
26
+ "JavaScriptError",
27
+ "Request",
28
+ "Response",
29
+ "Tab",
30
+ "find_firefox",
31
+ "protocol",
32
+ "start",
33
+ ]
barebidi/browser.py ADDED
@@ -0,0 +1,458 @@
1
+ import asyncio
2
+ import contextlib
3
+ import json
4
+ import logging
5
+ import os
6
+ import re
7
+ import shutil
8
+ import signal
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ from collections.abc import AsyncIterator, Callable, Iterable
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from .connection import BidiError, Connection, Handler
17
+ from .context import Context
18
+ from .dialogs import DialogHandler, Dialogs
19
+ from .downloads import Downloads
20
+ from .network import Intercept, InterceptHandler, Network, Phase
21
+ from .proxy import NO_PROXY_PREFS, Proxy, Relay
22
+ from .stealth import CLEAR_AUTOMATION_FLAG, HIDE_WEBDRIVER
23
+ from .stealth import PREFS as STEALTH_PREFS
24
+ from .tab import Tab
25
+
26
+ log = logging.getLogger(__name__)
27
+
28
+ _WS_URL = re.compile(rb"WebDriver BiDi listening on (ws://\S+)")
29
+
30
+
31
+ class _ExitedDuringStartup(RuntimeError):
32
+ pass
33
+
34
+ DEFAULT_HEADLESS_SCREEN = (1920, 1080)
35
+
36
+ ROUTED_MOUSE = "remote.events.async.mouse.enabled"
37
+
38
+ DEFAULT_PREFS: dict[str, Any] = {
39
+ "browser.shell.checkDefaultBrowser": False,
40
+ "browser.startup.homepage_override.mstone": "ignore",
41
+ "browser.aboutwelcome.enabled": False,
42
+ "browser.tabs.warnOnClose": False,
43
+ "browser.sessionstore.resume_from_crash": False,
44
+ "datareporting.policy.dataSubmissionEnabled": False,
45
+ "toolkit.telemetry.reportingpolicy.firstRun": False,
46
+ "app.update.disabledForTesting": True,
47
+ # Behind a proxy, WebRTC must not reveal the real IP by reaching STUN servers directly.
48
+ "media.peerconnection.ice.proxy_only_if_behind_proxy": True,
49
+ # Mouse and wheel input enters through the browser window and its hit testing, like real input, instead
50
+ # of being synthesized inside the page's process: it reaches cross-site iframes, with coherent coordinates.
51
+ ROUTED_MOUSE: True,
52
+ "remote.events.async.wheel.enabled": True,
53
+ }
54
+
55
+
56
+ def find_firefox() -> str:
57
+ if path := os.environ.get("FIREFOX_PATH"):
58
+ return path
59
+ candidates = {
60
+ "darwin": ["/Applications/Firefox.app/Contents/MacOS/firefox", "/Applications/Firefox Nightly.app/Contents/MacOS/firefox"],
61
+ "win32": [
62
+ rf"{os.environ.get('PROGRAMFILES', 'C:/Program Files')}\Mozilla Firefox\firefox.exe",
63
+ rf"{os.environ.get('PROGRAMFILES(X86)', 'C:/Program Files (x86)')}\Mozilla Firefox\firefox.exe",
64
+ ],
65
+ }.get(sys.platform, [])
66
+ for candidate in candidates:
67
+ if Path(candidate).exists():
68
+ return candidate
69
+ for name in ("firefox", "firefox-esr", "firefox-nightly"):
70
+ if found := shutil.which(name):
71
+ return found
72
+ raise FileNotFoundError("Firefox not found; install it or set FIREFOX_PATH")
73
+
74
+
75
+ def _is_snap(executable: str) -> bool:
76
+ """A snap-packaged Firefox (Ubuntu's default), directly or through the script Ubuntu puts in /usr/bin."""
77
+ resolved = Path(executable).resolve()
78
+ if resolved.parts[:2] == ("/", "snap"):
79
+ return True
80
+ try:
81
+ with open(resolved, "rb") as file:
82
+ head = file.read(4096)
83
+ except OSError:
84
+ return False
85
+ return head.startswith(b"#!") and b"/snap/" in head
86
+
87
+
88
+ class Browser:
89
+ """Firefox driven over WebDriver BiDi.
90
+
91
+ `proxy`, `locale` and `timezone` set up the default context (see Context); `profile` keeps its
92
+ cookies and storage between runs instead of starting from a temporary profile. `screen` is the
93
+ size of the virtual screen in headless mode; otherwise the real screen is used. With `humanize`,
94
+ the mouse travels in curves, keys are typed at a human rhythm and scrolling uses the wheel;
95
+ turn it off (here or later through the attribute) for speed. Downloaded files go to `downloads`,
96
+ or to a temporary folder that disappears with the browser. `timeout` is how many seconds Firefox
97
+ gets to answer each command (None waits forever); slow pages can get more through Tab.get.
98
+ """
99
+
100
+ def __init__(
101
+ self,
102
+ *,
103
+ headless: bool = False,
104
+ screen: tuple[int, int] | None = None,
105
+ stealth: bool = True,
106
+ humanize: bool = True,
107
+ profile: str | Path | None = None,
108
+ proxy: str | None = None,
109
+ locale: str | None = None,
110
+ timezone: str | None = None,
111
+ executable_path: str | None = None,
112
+ args: Iterable[str] = (),
113
+ prefs: dict[str, Any] | None = None,
114
+ env: dict[str, str] | None = None,
115
+ downloads: str | Path | None = None,
116
+ timeout: float | None = 60.0,
117
+ startup_timeout: float = 30.0,
118
+ ):
119
+ if screen and not headless:
120
+ raise ValueError("the screen size can only be chosen in headless mode; otherwise it is the real one")
121
+ self.headless = headless
122
+ self.screen = screen or DEFAULT_HEADLESS_SCREEN
123
+ self.stealth = stealth
124
+ self.humanize = humanize
125
+ self.profile = Path(profile) if profile else None
126
+ self.downloads = Path(downloads) if downloads else None # where files go; a temporary folder if None
127
+ self.proxy = Proxy.parse(proxy) if proxy else None
128
+ self.locale = locale
129
+ self.timezone = timezone
130
+ self.executable_path = executable_path
131
+ self.args = list(args)
132
+ self.env = dict(env or {}) # extra environment for the browser process (e.g. a patched build's config)
133
+ self.prefs = {
134
+ **DEFAULT_PREFS,
135
+ **(STEALTH_PREFS if stealth else {}),
136
+ **(self.proxy.prefs() if self.proxy else NO_PROXY_PREFS),
137
+ **(prefs or {}),
138
+ }
139
+ self.timeout = timeout # seconds Firefox gets to answer each command; mouse and keyboard add their own duration
140
+ self.startup_timeout = startup_timeout
141
+ self.connection: Connection
142
+ self.session_id: str | None = None
143
+ self.capabilities: dict = {}
144
+ self._process: asyncio.subprocess.Process | None = None
145
+ self._profile: str | None = None
146
+ self._stderr_task: asyncio.Task | None = None
147
+ self._parents: dict[str, str] = {}
148
+ self._user_contexts: dict[str, str] = {}
149
+ self._pointers: dict[str, tuple[int, int]] = {}
150
+ self._network = Network(self)
151
+ self._dialogs = Dialogs(self)
152
+ self._downloader = Downloads(self)
153
+ self._downloads_dir: str | None = None
154
+ self._scratch: Path | None = None
155
+ self._relays: list[Relay] = []
156
+ self.context = Context(self, "default")
157
+
158
+ async def __aenter__(self) -> "Browser":
159
+ return await self.start()
160
+
161
+ async def __aexit__(self, *exc: object) -> None:
162
+ await self.stop()
163
+
164
+ async def start(self) -> "Browser":
165
+ executable = self.executable_path or find_firefox()
166
+ # A snap's /tmp is its own: keep our temporary folders where a snap-packaged Firefox can reach them.
167
+ self._scratch = Path.home() / "snap" / "firefox" / "common" if _is_snap(executable) else None
168
+ if self._scratch:
169
+ self._scratch.mkdir(parents=True, exist_ok=True)
170
+ if self.profile:
171
+ self.profile.mkdir(parents=True, exist_ok=True)
172
+ self._profile = str(self.profile)
173
+ else:
174
+ self._profile = tempfile.mkdtemp(prefix="barebidi-", dir=self._scratch)
175
+ if self.proxy and self.proxy.credentials:
176
+ self.prefs |= (await self._relay(self.proxy)).local.prefs()
177
+ prefs = "".join(f"user_pref({json.dumps(k)}, {json.dumps(v)});\n" for k, v in self.prefs.items())
178
+ Path(self._profile, "user.js").write_text(prefs)
179
+
180
+ cmd = [executable, "--remote-debugging-port=0", "--profile", self._profile, "--no-remote"]
181
+ if self.headless:
182
+ cmd.append("--headless")
183
+ env = dict(os.environ)
184
+ if self.stealth:
185
+ # Access to the browser's own scope; older Firefox takes the flag, newer ones only the variable.
186
+ cmd.append("--remote-allow-system-access")
187
+ env["MOZ_REMOTE_ALLOW_SYSTEM_ACCESS"] = "1"
188
+ cmd += [*self.args, "about:blank"]
189
+ if self.headless:
190
+ width, height = self.screen
191
+ env |= {"MOZ_HEADLESS_WIDTH": str(width), "MOZ_HEADLESS_HEIGHT": str(height)}
192
+ env |= self.env # caller's env wins, so it can override our defaults
193
+ try:
194
+ url = await self._launch(cmd, env)
195
+ self._stderr_task = asyncio.create_task(self._drain_stderr())
196
+ self.connection = await Connection.connect(f"{url}/session", self.timeout)
197
+ # Dialogs wait for us to answer them (see Dialogs); native file pickers are cancelled at once.
198
+ prompts = {"default": "ignore", "file": "dismiss"}
199
+ session = await self.connection.send("session.new", {"capabilities": {"alwaysMatch": {"unhandledPromptBehavior": prompts}}})
200
+ await self._track_frames()
201
+ await self._watch_dialogs_and_downloads()
202
+ await self.context._emulate(self.locale, self.timezone)
203
+ if self.stealth:
204
+ await self._clear_automation_flag()
205
+ tab = await self._replace_initial_tab()
206
+ if self.headless:
207
+ await self._fit_window(tab)
208
+ if self.stealth and not await self._webdriver_hidden(tab):
209
+ log.info("this Firefox keeps navigator.webdriver on; hiding it with a preload script instead")
210
+ await self.connection.send("script.addPreloadScript", {"functionDeclaration": HIDE_WEBDRIVER})
211
+ except BaseException:
212
+ await self._cleanup(grace=0)
213
+ raise
214
+ self.session_id = session["sessionId"]
215
+ self.capabilities = session["capabilities"]
216
+ return self
217
+
218
+ async def stop(self) -> None:
219
+ if self._process is None:
220
+ return
221
+ if hasattr(self, "connection") and not self.connection.closed:
222
+ try:
223
+ await asyncio.wait_for(self.connection.send("browser.close"), 5)
224
+ except Exception:
225
+ pass
226
+ await self.connection.close()
227
+ await self._cleanup()
228
+
229
+ async def tabs(self) -> list[Tab]:
230
+ tree = await self.connection.send("browsingContext.getTree", {"maxDepth": 0})
231
+ return [Tab(self, c["context"]) for c in tree["contexts"]]
232
+
233
+ async def new_tab(self, url: str | None = None, background: bool = False) -> Tab:
234
+ return await self.context.new_tab(url, background)
235
+
236
+ async def get(self, url: str, new_tab: bool = False) -> Tab:
237
+ return await self.context.get(url, new_tab)
238
+
239
+ async def new_context(self, *, proxy: str | None = None, locale: str | None = None, timezone: str | None = None) -> Context:
240
+ """A new isolated identity, gone when closed or when the browser stops."""
241
+ parsed = Proxy.parse(proxy) if proxy else None
242
+ relay = await self._relay(parsed) if parsed and parsed.credentials else None
243
+ config = relay.local if relay else parsed
244
+ result = await self.connection.send("browser.createUserContext", {"proxy": config.bidi()} if config else {})
245
+ context = Context(self, result["userContext"])
246
+ context._relay = relay
247
+ await context._emulate(locale, timezone)
248
+ return context
249
+
250
+ async def cookies(self, domain: str | None = None) -> list[dict]:
251
+ return await self.context.cookies(domain)
252
+
253
+ async def set_cookies(self, cookies: Iterable[dict]) -> None:
254
+ await self.context.set_cookies(cookies)
255
+
256
+ async def clear_cookies(self) -> None:
257
+ await self.context.clear_cookies()
258
+
259
+ async def on_dialog(self, handler: DialogHandler) -> Callable[[], None]:
260
+ """Like Tab.on_dialog, for the dialogs of every tab."""
261
+ return self._dialogs.add(handler)
262
+
263
+ async def on(self, event: str, handler: Handler, contexts: list[str] | None = None) -> str:
264
+ """Subscribe to a BiDi event or module ("network.responseCompleted", "log")."""
265
+ self.connection.on(event, handler)
266
+ params: dict = {"events": [event]}
267
+ if contexts:
268
+ params["contexts"] = contexts
269
+ result = await self.connection.send("session.subscribe", params)
270
+ return result.get("subscription", "")
271
+
272
+ async def intercept(self, handler: InterceptHandler, url: str | None = None, phase: Phase = "request") -> Intercept:
273
+ """Pause matching requests in every tab and pass them to `handler`.
274
+
275
+ The handler settles each one with continue_(), abort() or fulfill(); if it doesn't,
276
+ the next matching intercept gets it, and if none does the request continues untouched.
277
+ """
278
+ return await self._network.intercept(handler, url, phase)
279
+
280
+ async def block(self, *urls: str, resource_types: Iterable[str] = ()) -> Intercept:
281
+ """Abort requests matching any URL pattern or resource type ("image", "font", "media", ...)."""
282
+ return await self._network.block(urls, resource_types)
283
+
284
+ async def set_extra_headers(self, headers: dict[str, str]) -> None:
285
+ """Add headers to every request; an empty dict removes them."""
286
+ await self._network.set_extra_headers(headers)
287
+
288
+ def top_level(self, context: str) -> str:
289
+ """The tab a browsing context (e.g. an iframe) belongs to."""
290
+ while context in self._parents:
291
+ context = self._parents[context]
292
+ return context
293
+
294
+ def user_context_of(self, context: str) -> str:
295
+ return self._user_contexts.get(context, "default")
296
+
297
+ async def _relay(self, proxy: Proxy) -> Relay:
298
+ """Firefox's way to a proxy with credentials, which it then never has to handle (see Relay)."""
299
+ relay = await Relay(proxy).start()
300
+ self._relays.append(relay)
301
+ return relay
302
+
303
+ async def _clear_automation_flag(self) -> None:
304
+ await self._chrome_eval(CLEAR_AUTOMATION_FLAG)
305
+
306
+ @contextlib.asynccontextmanager
307
+ async def _input_inside_frames(self) -> AsyncIterator[None]:
308
+ """Mouse input sent to a frame goes straight into its document instead of through the window."""
309
+ switch = f"Services.prefs.setBoolPref({json.dumps(ROUTED_MOUSE)}, %s)"
310
+ switched = bool(self.prefs.get(ROUTED_MOUSE)) and await self._chrome_eval(switch % "false")
311
+ if self.prefs.get(ROUTED_MOUSE) and not switched:
312
+ log.warning("no access to the browser's scope (it needs stealth=True): mouse input can't go straight into the frame")
313
+ try:
314
+ yield
315
+ finally:
316
+ if switched:
317
+ await self._chrome_eval(switch % "true")
318
+
319
+ async def _chrome_eval(self, expression: str) -> bool:
320
+ """Run `expression` in the browser's own scope, which needs the system access stealth mode asks for."""
321
+ try:
322
+ tree = await self.connection.send("browsingContext.getTree", {"moz:scope": "chrome", "maxDepth": 0})
323
+ target = {"context": tree["contexts"][0]["context"]}
324
+ result = await self.connection.send("script.evaluate", {"expression": expression, "target": target, "awaitPromise": False})
325
+ except (BidiError, IndexError) as error:
326
+ log.debug("cannot run in the browser's scope: %s", error)
327
+ return False
328
+ return result.get("type") != "exception"
329
+
330
+ async def _webdriver_hidden(self, tab: Tab) -> bool:
331
+ for _ in range(10): # the flag reaches content processes asynchronously
332
+ if not await tab.evaluate("navigator.webdriver", isolated=True):
333
+ return True
334
+ await asyncio.sleep(0.05)
335
+ return False
336
+
337
+ async def _fit_window(self, tab: Tab) -> None:
338
+ # Headless Firefox spreads its window over the whole screen, menu bar and dock included, which no
339
+ # real window does. Place it over the available area instead, like a maximized window.
340
+ x, y, width, height = await tab.evaluate(
341
+ "[screen.availLeft, screen.availTop, screen.availWidth, screen.availHeight]", isolated=True
342
+ )
343
+ window = (await self.connection.send("browser.getClientWindows"))["clientWindows"][0]["clientWindow"]
344
+ await self.connection.send(
345
+ "browser.setClientWindowState",
346
+ {"clientWindow": window, "state": "normal", "x": x, "y": y, "width": width, "height": height},
347
+ )
348
+
349
+ async def _replace_initial_tab(self) -> Tab:
350
+ # Firefox focuses the address bar of the startup about:blank tab, so documents loaded there never
351
+ # get focus (document.hasFocus() stays false). Tabs opened over BiDi get content focus.
352
+ initial = await self.tabs()
353
+ tab = await self.new_tab()
354
+ for old in initial:
355
+ await old.close()
356
+ return tab
357
+
358
+ async def _track_frames(self) -> None:
359
+ def created(params: dict) -> None:
360
+ self._user_contexts[params["context"]] = params.get("userContext", "default")
361
+ if params.get("parent"):
362
+ self._parents[params["context"]] = params["parent"]
363
+
364
+ def destroyed(params: dict) -> None:
365
+ self._parents.pop(params["context"], None)
366
+ self._user_contexts.pop(params["context"], None)
367
+ for child in params.get("children") or ():
368
+ destroyed(child)
369
+
370
+ self.connection.on("browsingContext.contextCreated", created)
371
+ self.connection.on("browsingContext.contextDestroyed", destroyed)
372
+ await self.connection.send(
373
+ "session.subscribe", {"events": ["browsingContext.contextCreated", "browsingContext.contextDestroyed"]}
374
+ )
375
+
376
+ async def _watch_dialogs_and_downloads(self) -> None:
377
+ self.connection.on("browsingContext.userPromptOpened", self._dialogs.opened)
378
+ self.connection.on("browsingContext.downloadWillBegin", self._downloader.begun)
379
+ self.connection.on("browsingContext.downloadEnd", self._downloader.ended)
380
+ events = ["browsingContext.userPromptOpened", "browsingContext.downloadWillBegin", "browsingContext.downloadEnd"]
381
+ await self.connection.send("session.subscribe", {"events": events})
382
+ if self.downloads:
383
+ self.downloads.mkdir(parents=True, exist_ok=True)
384
+ self._downloads_dir = str(self.downloads.resolve())
385
+ else:
386
+ self._downloads_dir = tempfile.mkdtemp(prefix="barebidi-downloads-", dir=self._scratch)
387
+ behavior = {"type": "allowed", "destinationFolder": self._downloads_dir}
388
+ await self.connection.send("browser.setDownloadBehavior", {"downloadBehavior": behavior})
389
+
390
+ async def _launch(self, cmd: list[str], env: dict[str, str]) -> str:
391
+ """Start Firefox and return its WebDriver BiDi URL."""
392
+ for attempt in range(2):
393
+ log.debug("launching %s", cmd)
394
+ self._process = await asyncio.create_subprocess_exec(
395
+ *cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, env=env
396
+ )
397
+ try:
398
+ return await asyncio.wait_for(self._read_ws_url(), self.startup_timeout)
399
+ except _ExitedDuringStartup:
400
+ # Right after an update Firefox may restart itself to finish it: the copy that carries on
401
+ # isn't one we can talk to, so close it and start again.
402
+ await self._kill_strays()
403
+ if attempt:
404
+ raise
405
+ raise AssertionError("unreachable")
406
+
407
+ async def _read_ws_url(self) -> str:
408
+ assert self._process and self._process.stderr
409
+ output = []
410
+ while line := await self._process.stderr.readline():
411
+ output.append(line)
412
+ if match := _WS_URL.search(line):
413
+ return match.group(1).decode()
414
+ raise _ExitedDuringStartup("Firefox exited during startup:\n" + b"".join(output).decode(errors="replace"))
415
+
416
+ async def _kill_strays(self) -> None:
417
+ """Close every Firefox still running on our profile, such as one that restarted itself."""
418
+ if not self._profile or sys.platform == "win32":
419
+ return
420
+ ours = f"--remote-debugging-port=0 --profile {self._profile}" # only processes we launched
421
+ for attempt in range(50):
422
+ ps = await asyncio.create_subprocess_exec("ps", "-A", "-ww", "-o", "pid=,command=", stdout=subprocess.PIPE)
423
+ listing = (await ps.communicate())[0].decode(errors="replace")
424
+ pids = [int(line.split(None, 1)[0]) for line in listing.splitlines() if ours in line]
425
+ if not pids:
426
+ return
427
+ for pid in pids:
428
+ with contextlib.suppress(ProcessLookupError):
429
+ os.kill(pid, signal.SIGKILL if attempt >= 30 else signal.SIGTERM)
430
+ await asyncio.sleep(0.1)
431
+
432
+ async def _drain_stderr(self) -> None:
433
+ assert self._process and self._process.stderr
434
+ while line := await self._process.stderr.readline():
435
+ log.debug("firefox: %s", line.decode(errors="replace").rstrip())
436
+
437
+ async def _cleanup(self, grace: float = 10) -> None:
438
+ if self._process and self._process.returncode is None:
439
+ try:
440
+ await asyncio.wait_for(self._process.wait(), grace)
441
+ except TimeoutError:
442
+ self._process.kill()
443
+ await self._process.wait()
444
+ if self._stderr_task:
445
+ self._stderr_task.cancel()
446
+ await self._kill_strays()
447
+ for relay in self._relays:
448
+ relay.close()
449
+ self._relays.clear()
450
+ if self._profile and self.profile is None:
451
+ shutil.rmtree(self._profile, ignore_errors=True)
452
+ if self._downloads_dir and self.downloads is None:
453
+ shutil.rmtree(self._downloads_dir, ignore_errors=True)
454
+ self._process = None
455
+
456
+
457
+ async def start(**kwargs: Any) -> Browser:
458
+ return await Browser(**kwargs).start()
barebidi/connection.py ADDED
@@ -0,0 +1,123 @@
1
+ import asyncio
2
+ import itertools
3
+ import json
4
+ import logging
5
+ from collections import defaultdict
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+
9
+ from websockets.asyncio.client import ClientConnection, connect
10
+ from websockets.exceptions import ConnectionClosed
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+ Handler = Callable[[dict], Any]
15
+
16
+ _GONE = "Firefox closed the connection: it was stopped, exited or crashed"
17
+
18
+
19
+ class BidiError(Exception):
20
+ def __init__(self, error: str, message: str, stacktrace: str | None = None):
21
+ super().__init__(f"{error}: {message}")
22
+ self.error = error
23
+ self.message = message
24
+ self.stacktrace = stacktrace
25
+
26
+
27
+ class BrowserClosedError(ConnectionError):
28
+ """Firefox is gone: it was stopped, exited or crashed."""
29
+
30
+
31
+ class Connection:
32
+ def __init__(self, ws: ClientConnection, timeout: float | None = None):
33
+ self._ws = ws
34
+ self.timeout = timeout # default seconds to wait for each answer; None waits forever
35
+ self._ids = itertools.count(1)
36
+ self._pending: dict[int, asyncio.Future] = {}
37
+ self._handlers: dict[str, list[Handler]] = defaultdict(list)
38
+ self._tasks: set[asyncio.Task] = set()
39
+ self._reader = asyncio.create_task(self._read_loop())
40
+
41
+ @classmethod
42
+ async def connect(cls, url: str, timeout: float | None = None) -> "Connection":
43
+ return cls(await connect(url, max_size=None), timeout)
44
+
45
+ @property
46
+ def closed(self) -> bool:
47
+ return self._reader.done()
48
+
49
+ async def send(self, method: str, params: dict | None = None, timeout: float | None = None) -> dict:
50
+ """Send a command and wait for its result, at most `timeout` seconds (by default, the connection's)."""
51
+ if self.closed:
52
+ raise BrowserClosedError(_GONE)
53
+ msg_id = next(self._ids)
54
+ future = asyncio.get_running_loop().create_future()
55
+ self._pending[msg_id] = future
56
+ payload = json.dumps({"id": msg_id, "method": method, "params": params or {}})
57
+ log.debug("-> %s", payload)
58
+ timeout = self.timeout if timeout is None else timeout
59
+ try:
60
+ try:
61
+ await self._ws.send(payload)
62
+ except ConnectionClosed as error:
63
+ raise BrowserClosedError(_GONE) from error
64
+ try:
65
+ return await asyncio.wait_for(future, timeout)
66
+ except TimeoutError:
67
+ raise TimeoutError(f"Firefox didn't answer {method} within {timeout}s") from None
68
+ finally:
69
+ self._pending.pop(msg_id, None)
70
+
71
+ def on(self, event: str, handler: Handler) -> None:
72
+ """Register a local handler. `event` may be a full name or a module ("network")."""
73
+ self._handlers[event].append(handler)
74
+
75
+ def off(self, event: str, handler: Handler) -> None:
76
+ if handler in self._handlers.get(event, []):
77
+ self._handlers[event].remove(handler)
78
+
79
+ async def close(self) -> None:
80
+ await self._ws.close()
81
+ await asyncio.gather(self._reader, return_exceptions=True)
82
+
83
+ async def _read_loop(self) -> None:
84
+ try:
85
+ async for raw in self._ws:
86
+ log.debug("<- %s", raw)
87
+ msg = json.loads(raw)
88
+ if msg.get("type") == "event":
89
+ self._dispatch(msg["method"], msg.get("params", {}))
90
+ else:
91
+ self._resolve(msg)
92
+ except ConnectionClosed:
93
+ pass
94
+ finally:
95
+ for future in self._pending.values():
96
+ if not future.done():
97
+ future.set_exception(BrowserClosedError(_GONE))
98
+
99
+ def _resolve(self, msg: dict) -> None:
100
+ future = self._pending.get(msg.get("id"))
101
+ if future is None:
102
+ if msg.get("type") == "error":
103
+ log.warning("unmatched error: %s", msg)
104
+ return
105
+ if future.done():
106
+ return
107
+ if msg.get("type") == "error":
108
+ future.set_exception(BidiError(msg["error"], msg.get("message", ""), msg.get("stacktrace")))
109
+ else:
110
+ future.set_result(msg.get("result", {}))
111
+
112
+ def _dispatch(self, method: str, params: dict) -> None:
113
+ module = method.split(".", 1)[0]
114
+ for handler in [*self._handlers.get(method, ()), *self._handlers.get(module, ())]:
115
+ try:
116
+ result = handler(params)
117
+ except Exception:
118
+ log.exception("event handler for %s failed", method)
119
+ continue
120
+ if asyncio.iscoroutine(result):
121
+ task = asyncio.create_task(result)
122
+ self._tasks.add(task)
123
+ task.add_done_callback(self._tasks.discard)