ai-dev-browser 0.5.3__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.
Files changed (155) hide show
  1. ai_dev_browser/__init__.py +110 -0
  2. ai_dev_browser/_cli.py +428 -0
  3. ai_dev_browser/_version.py +13 -0
  4. ai_dev_browser/cdp/__init__.py +6 -0
  5. ai_dev_browser/cdp/accessibility.py +668 -0
  6. ai_dev_browser/cdp/animation.py +494 -0
  7. ai_dev_browser/cdp/audits.py +1990 -0
  8. ai_dev_browser/cdp/autofill.py +292 -0
  9. ai_dev_browser/cdp/background_service.py +215 -0
  10. ai_dev_browser/cdp/bluetooth_emulation.py +626 -0
  11. ai_dev_browser/cdp/browser.py +821 -0
  12. ai_dev_browser/cdp/cache_storage.py +311 -0
  13. ai_dev_browser/cdp/cast.py +172 -0
  14. ai_dev_browser/cdp/console.py +107 -0
  15. ai_dev_browser/cdp/crash_report_context.py +55 -0
  16. ai_dev_browser/cdp/css.py +2710 -0
  17. ai_dev_browser/cdp/debugger.py +1405 -0
  18. ai_dev_browser/cdp/device_access.py +141 -0
  19. ai_dev_browser/cdp/device_orientation.py +45 -0
  20. ai_dev_browser/cdp/dom.py +2257 -0
  21. ai_dev_browser/cdp/dom_debugger.py +321 -0
  22. ai_dev_browser/cdp/dom_snapshot.py +876 -0
  23. ai_dev_browser/cdp/dom_storage.py +222 -0
  24. ai_dev_browser/cdp/emulation.py +1779 -0
  25. ai_dev_browser/cdp/event_breakpoints.py +56 -0
  26. ai_dev_browser/cdp/extensions.py +246 -0
  27. ai_dev_browser/cdp/fed_cm.py +283 -0
  28. ai_dev_browser/cdp/fetch.py +507 -0
  29. ai_dev_browser/cdp/file_system.py +115 -0
  30. ai_dev_browser/cdp/headless_experimental.py +115 -0
  31. ai_dev_browser/cdp/heap_profiler.py +401 -0
  32. ai_dev_browser/cdp/indexed_db.py +528 -0
  33. ai_dev_browser/cdp/input_.py +701 -0
  34. ai_dev_browser/cdp/inspector.py +95 -0
  35. ai_dev_browser/cdp/io.py +101 -0
  36. ai_dev_browser/cdp/layer_tree.py +464 -0
  37. ai_dev_browser/cdp/log.py +190 -0
  38. ai_dev_browser/cdp/media.py +313 -0
  39. ai_dev_browser/cdp/memory.py +305 -0
  40. ai_dev_browser/cdp/network.py +5317 -0
  41. ai_dev_browser/cdp/overlay.py +1468 -0
  42. ai_dev_browser/cdp/page.py +3970 -0
  43. ai_dev_browser/cdp/performance.py +124 -0
  44. ai_dev_browser/cdp/performance_timeline.py +200 -0
  45. ai_dev_browser/cdp/preload.py +575 -0
  46. ai_dev_browser/cdp/profiler.py +420 -0
  47. ai_dev_browser/cdp/pwa.py +278 -0
  48. ai_dev_browser/cdp/py.typed +0 -0
  49. ai_dev_browser/cdp/runtime.py +1589 -0
  50. ai_dev_browser/cdp/schema.py +50 -0
  51. ai_dev_browser/cdp/security.py +518 -0
  52. ai_dev_browser/cdp/service_worker.py +401 -0
  53. ai_dev_browser/cdp/smart_card_emulation.py +891 -0
  54. ai_dev_browser/cdp/storage.py +1573 -0
  55. ai_dev_browser/cdp/system_info.py +327 -0
  56. ai_dev_browser/cdp/target.py +822 -0
  57. ai_dev_browser/cdp/tethering.py +65 -0
  58. ai_dev_browser/cdp/tracing.py +377 -0
  59. ai_dev_browser/cdp/util.py +17 -0
  60. ai_dev_browser/cdp/web_audio.py +606 -0
  61. ai_dev_browser/cdp/web_authn.py +598 -0
  62. ai_dev_browser/cdp/web_mcp.py +219 -0
  63. ai_dev_browser/core/__init__.py +218 -0
  64. ai_dev_browser/core/_case.py +38 -0
  65. ai_dev_browser/core/_cf_template.py +84 -0
  66. ai_dev_browser/core/_element.py +431 -0
  67. ai_dev_browser/core/_tab.py +817 -0
  68. ai_dev_browser/core/_transport.py +362 -0
  69. ai_dev_browser/core/ax.py +605 -0
  70. ai_dev_browser/core/browser.py +323 -0
  71. ai_dev_browser/core/cdp.py +66 -0
  72. ai_dev_browser/core/chrome.py +253 -0
  73. ai_dev_browser/core/cloudflare.py +77 -0
  74. ai_dev_browser/core/config.py +95 -0
  75. ai_dev_browser/core/connection.py +403 -0
  76. ai_dev_browser/core/cookies.py +103 -0
  77. ai_dev_browser/core/dialog.py +165 -0
  78. ai_dev_browser/core/download.py +38 -0
  79. ai_dev_browser/core/elements.py +913 -0
  80. ai_dev_browser/core/human.py +612 -0
  81. ai_dev_browser/core/login.py +122 -0
  82. ai_dev_browser/core/mouse.py +140 -0
  83. ai_dev_browser/core/navigation.py +225 -0
  84. ai_dev_browser/core/overlays.py +148 -0
  85. ai_dev_browser/core/page.py +302 -0
  86. ai_dev_browser/core/port.py +465 -0
  87. ai_dev_browser/core/process.py +114 -0
  88. ai_dev_browser/core/snapshot.py +425 -0
  89. ai_dev_browser/core/storage.py +50 -0
  90. ai_dev_browser/core/tabs.py +125 -0
  91. ai_dev_browser/core/text_match.py +217 -0
  92. ai_dev_browser/core/window.py +84 -0
  93. ai_dev_browser/pool/__init__.py +34 -0
  94. ai_dev_browser/pool/job.py +140 -0
  95. ai_dev_browser/pool/persistence.py +154 -0
  96. ai_dev_browser/pool/pool.py +873 -0
  97. ai_dev_browser/pool/worker.py +171 -0
  98. ai_dev_browser/profile.py +107 -0
  99. ai_dev_browser/py.typed +0 -0
  100. ai_dev_browser/tools/__init__.py +9 -0
  101. ai_dev_browser/tools/_generate.py +209 -0
  102. ai_dev_browser/tools/browser_list.py +14 -0
  103. ai_dev_browser/tools/browser_start.py +14 -0
  104. ai_dev_browser/tools/browser_stop.py +14 -0
  105. ai_dev_browser/tools/cdp_send.py +14 -0
  106. ai_dev_browser/tools/click_by_html_id.py +14 -0
  107. ai_dev_browser/tools/click_by_ref.py +14 -0
  108. ai_dev_browser/tools/click_by_text.py +14 -0
  109. ai_dev_browser/tools/click_by_xpath.py +14 -0
  110. ai_dev_browser/tools/cloudflare_verify.py +14 -0
  111. ai_dev_browser/tools/cookies_list.py +14 -0
  112. ai_dev_browser/tools/cookies_load.py +14 -0
  113. ai_dev_browser/tools/cookies_save.py +14 -0
  114. ai_dev_browser/tools/dialog_respond.py +14 -0
  115. ai_dev_browser/tools/download.py +14 -0
  116. ai_dev_browser/tools/drag_by_ref.py +14 -0
  117. ai_dev_browser/tools/find_by_html_id.py +14 -0
  118. ai_dev_browser/tools/find_by_xpath.py +14 -0
  119. ai_dev_browser/tools/focus_by_ref.py +14 -0
  120. ai_dev_browser/tools/highlight_by_ref.py +14 -0
  121. ai_dev_browser/tools/hover_by_ref.py +14 -0
  122. ai_dev_browser/tools/html_by_ref.py +14 -0
  123. ai_dev_browser/tools/js_evaluate.py +14 -0
  124. ai_dev_browser/tools/login_interactive.py +14 -0
  125. ai_dev_browser/tools/mouse_click.py +14 -0
  126. ai_dev_browser/tools/mouse_drag.py +14 -0
  127. ai_dev_browser/tools/mouse_move.py +14 -0
  128. ai_dev_browser/tools/page_discover.py +14 -0
  129. ai_dev_browser/tools/page_emulate_focus.py +14 -0
  130. ai_dev_browser/tools/page_goto.py +14 -0
  131. ai_dev_browser/tools/page_html.py +14 -0
  132. ai_dev_browser/tools/page_info.py +14 -0
  133. ai_dev_browser/tools/page_reload.py +14 -0
  134. ai_dev_browser/tools/page_screenshot.py +14 -0
  135. ai_dev_browser/tools/page_scroll.py +14 -0
  136. ai_dev_browser/tools/page_wait_element.py +14 -0
  137. ai_dev_browser/tools/page_wait_ready.py +14 -0
  138. ai_dev_browser/tools/page_wait_url.py +14 -0
  139. ai_dev_browser/tools/screenshot_by_ref.py +14 -0
  140. ai_dev_browser/tools/select_by_ref.py +14 -0
  141. ai_dev_browser/tools/storage_get.py +14 -0
  142. ai_dev_browser/tools/storage_set.py +14 -0
  143. ai_dev_browser/tools/tab_close.py +14 -0
  144. ai_dev_browser/tools/tab_list.py +14 -0
  145. ai_dev_browser/tools/tab_new.py +14 -0
  146. ai_dev_browser/tools/tab_switch.py +14 -0
  147. ai_dev_browser/tools/type_by_ref.py +14 -0
  148. ai_dev_browser/tools/type_by_text.py +14 -0
  149. ai_dev_browser/tools/upload_by_ref.py +14 -0
  150. ai_dev_browser/tools/window_set.py +14 -0
  151. ai_dev_browser-0.5.3.dist-info/METADATA +177 -0
  152. ai_dev_browser-0.5.3.dist-info/RECORD +155 -0
  153. ai_dev_browser-0.5.3.dist-info/WHEEL +5 -0
  154. ai_dev_browser-0.5.3.dist-info/licenses/LICENSE +25 -0
  155. ai_dev_browser-0.5.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,362 @@
1
+ """Async WebSocket transport for Chrome DevTools Protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import collections
7
+ import importlib
8
+ import itertools
9
+ import json
10
+ import logging
11
+ import types
12
+ from asyncio import iscoroutinefunction
13
+ from typing import Any, Callable, Generator
14
+
15
+ import websockets
16
+ import websockets.asyncio.client
17
+ import websockets.exceptions
18
+
19
+ from ai_dev_browser import cdp
20
+
21
+ from ._case import camel_to_snake
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+ MAX_SIZE: int = 2**28
26
+ PING_TIMEOUT: int = 900 # 15 minutes
27
+ COMMAND_TIMEOUT: int = 30 # seconds per CDP command
28
+
29
+
30
+ class ProtocolException(Exception):
31
+ """CDP protocol error."""
32
+
33
+ def __init__(self, *args, method: str = None, params: dict = None):
34
+ self.message = None
35
+ self.code = None
36
+ self.method = method # For retry: original CDP method
37
+ self.params = params # For retry: original CDP params
38
+ self.args = args
39
+ if isinstance(args[0], dict):
40
+ self.message = args[0].get("message")
41
+ self.code = args[0].get("code")
42
+ else:
43
+ self.message = "| ".join(str(x) for x in args)
44
+
45
+ def __str__(self):
46
+ return f"{self.message} [code: {self.code}]" if self.code else f"{self.message}"
47
+
48
+
49
+ class Transaction(asyncio.Future):
50
+ """Wraps a CDP generator into a Future that resolves when response arrives.
51
+
52
+ CDP methods return generators that:
53
+ 1. yield {"method": ..., "params": ...} (the request)
54
+ 2. receive the response dict via .send()
55
+ 3. raise StopIteration with parsed result as .value
56
+ """
57
+
58
+ def __init__(self, cdp_obj: Generator):
59
+ super().__init__()
60
+ self.__cdp_obj__ = cdp_obj
61
+ self.id: int | None = None
62
+ self.method, *params = next(self.__cdp_obj__).values()
63
+ self.params = params.pop() if params else {}
64
+
65
+ @property
66
+ def message(self) -> str:
67
+ return json.dumps({"method": self.method, "params": self.params, "id": self.id})
68
+
69
+ def __call__(self, **response): # type: ignore[override]
70
+ """Process CDP response: feed to generator or set exception."""
71
+ if "error" in response:
72
+ return self.set_exception(ProtocolException(response["error"]))
73
+ try:
74
+ self.__cdp_obj__.send(response.get("result", {})) # type: ignore[arg-type]
75
+ except StopIteration as e:
76
+ self.set_result(e.value)
77
+ except KeyError as e:
78
+ raise KeyError(f"key '{e.args}' not found in response: {response}") from e
79
+
80
+
81
+ def _cdp_get_module(domain: str | types.ModuleType):
82
+ """Get CDP domain module by name or module reference."""
83
+ if isinstance(domain, types.ModuleType):
84
+ return domain
85
+ if domain == "input":
86
+ domain = "input_"
87
+ mod = getattr(cdp, domain, None)
88
+ if mod:
89
+ return mod
90
+ return importlib.import_module(domain)
91
+
92
+
93
+ class CDPConnection:
94
+ """Async WebSocket connection to a CDP endpoint.
95
+
96
+ Handles sending CDP commands (generator protocol), receiving responses,
97
+ and dispatching CDP events to registered handlers.
98
+ """
99
+
100
+ def __init__(self, websocket_url: str):
101
+ self.websocket_url = websocket_url
102
+ self._websocket: websockets.asyncio.client.ClientConnection | None = None
103
+ self._listener_task: asyncio.Task | None = None
104
+ self._counter = itertools.count(0)
105
+ self._pending: dict[int, Transaction] = {}
106
+ self.handlers: dict[type, list[Callable]] = collections.defaultdict(list)
107
+ self.enabled_domains: list = []
108
+
109
+ @property
110
+ def closed(self) -> bool:
111
+ if not self._websocket:
112
+ return True
113
+ return bool(self._websocket.close_code)
114
+
115
+ async def connect(self):
116
+ """Open WebSocket and start listener."""
117
+ if self._websocket and not self._websocket.close_code:
118
+ return # already connected
119
+ self._websocket = await websockets.connect(
120
+ self.websocket_url,
121
+ ping_timeout=PING_TIMEOUT,
122
+ max_size=MAX_SIZE,
123
+ )
124
+ self._listener_task = asyncio.ensure_future(self._listener())
125
+ await self._register_handlers()
126
+
127
+ async def disconnect(self):
128
+ """Close WebSocket and stop listener."""
129
+ if self._listener_task:
130
+ self._listener_task.cancel()
131
+ self._listener_task = None
132
+ if self._websocket:
133
+ self.enabled_domains.clear()
134
+ await self._websocket.close()
135
+
136
+ async def send(
137
+ self,
138
+ cdp_obj: Generator[dict[str, Any], dict[str, Any], Any],
139
+ _is_update: bool = False,
140
+ *,
141
+ timeout: float | None = None,
142
+ ) -> Any:
143
+ """Send a CDP command and await the response.
144
+
145
+ Auto-reconnects if WebSocket is dead. On send failure, reconnects
146
+ and raises (caller can retry with a fresh generator).
147
+
148
+ Args:
149
+ timeout: Per-call timeout in seconds. None uses COMMAND_TIMEOUT.
150
+ Pass a larger value for commands known to take time
151
+ (e.g. Runtime.evaluate with await_promise on a long fetch).
152
+ """
153
+ if self.closed:
154
+ await self.connect()
155
+ if not _is_update:
156
+ await self._register_handlers()
157
+ tx = Transaction(cdp_obj)
158
+ tx.id = next(self._counter)
159
+ self._pending[tx.id] = tx
160
+ try:
161
+ await self._websocket.send(tx.message)
162
+ except Exception as e:
163
+ self._pending.pop(tx.id, None)
164
+ # Connection is broken — force reconnect so next call works
165
+ logger.debug("WebSocket send failed, forcing reconnect: %s", e)
166
+ await self._force_reconnect()
167
+ raise ProtocolException(f"WebSocket send failed: {e}")
168
+ effective_timeout = timeout if timeout is not None else COMMAND_TIMEOUT
169
+ try:
170
+ return await asyncio.wait_for(tx, timeout=effective_timeout)
171
+ except asyncio.TimeoutError:
172
+ self._pending.pop(tx.id, None)
173
+ logger.debug("CDP command timed out (%s), forcing reconnect", tx.method)
174
+ await self._force_reconnect()
175
+ raise ProtocolException(
176
+ f"CDP command timed out after {effective_timeout}s: {tx.method}",
177
+ method=tx.method,
178
+ params=tx.params,
179
+ )
180
+
181
+ async def send_raw(self, method: str, params: dict) -> Any:
182
+ """Send a raw CDP command and return the parsed result.
183
+
184
+ Used for retries where the original CDP generator is consumed.
185
+ Reconstructs a fresh generator from the CDP module to parse the
186
+ response with the same typed protocol as normal send().
187
+ """
188
+ # Reconstruct the CDP generator from method name + params
189
+ # e.g. "Page.captureScreenshot" → cdp.page.capture_screenshot(**params)
190
+ domain_name, cmd_name = method.split(".")
191
+ domain_mod = _cdp_get_module(domain_name.lower())
192
+ cmd_func = getattr(domain_mod, camel_to_snake(cmd_name))
193
+
194
+ snake_params = (
195
+ {camel_to_snake(k): v for k, v in params.items()} if params else {}
196
+ )
197
+
198
+ # Fail fast with full context if cdp-python rejects a kwarg —
199
+ # masking the TypeError and retrying with cmd_func() (the previous
200
+ # behaviour) hid case-conversion bugs behind misleading "missing
201
+ # required positional argument" errors from the empty retry.
202
+ try:
203
+ cdp_obj = cmd_func(**snake_params)
204
+ except TypeError as e:
205
+ raise TypeError(
206
+ f"CDP {method}: cdp-python rejected kwargs {sorted(snake_params)} — {e}"
207
+ ) from e
208
+
209
+ return await self.send(cdp_obj, _is_update=True)
210
+
211
+ async def _force_reconnect(self):
212
+ """Disconnect and reconnect the WebSocket."""
213
+ try:
214
+ await self.disconnect()
215
+ except Exception:
216
+ pass
217
+ await self.connect()
218
+
219
+ def add_handler(
220
+ self,
221
+ event_type: type | types.ModuleType | list[type],
222
+ handler: Callable,
223
+ ):
224
+ """Register an event handler.
225
+
226
+ Args:
227
+ event_type: CDP event class, module (registers all events in module),
228
+ or list of event classes.
229
+ handler: Sync or async callback. Called with (event) or (event, connection).
230
+ """
231
+ if not isinstance(event_type, list):
232
+ event_type = [event_type] # type: ignore[assignment,list-item]
233
+ for evt in event_type:
234
+ if isinstance(evt, types.ModuleType):
235
+ import inspect
236
+
237
+ for name, obj in inspect.getmembers_static(evt):
238
+ if name.isupper() or not name[0].isupper():
239
+ continue
240
+ if type(obj) is type:
241
+ self.handlers[obj].append(handler)
242
+ else:
243
+ self.handlers[evt].append(handler)
244
+
245
+ # Domains that should never be removed by _register_handlers cleanup.
246
+ # These are either always-on (target, storage, input_) or essential
247
+ # for core operations (page, dom) — enabled by Tab._ensure_connected().
248
+ _PROTECTED_DOMAINS = None # Populated lazily to avoid import-time issues
249
+
250
+ @classmethod
251
+ def _get_protected_domains(cls):
252
+ if cls._PROTECTED_DOMAINS is None:
253
+ cls._PROTECTED_DOMAINS = {
254
+ cdp.target,
255
+ cdp.storage,
256
+ cdp.input_,
257
+ cdp.page,
258
+ cdp.dom,
259
+ }
260
+ return cls._PROTECTED_DOMAINS
261
+
262
+ async def _register_handlers(self):
263
+ """Auto-enable CDP domains for registered event handlers.
264
+
265
+ Does NOT remove domains that were explicitly enabled (page, dom, etc.)
266
+ even if they have no event handlers — those are needed for commands.
267
+ """
268
+ protected = self._get_protected_domains()
269
+ enabled_copy = self.enabled_domains.copy()
270
+ for event_type in list(self.handlers):
271
+ if not self.handlers[event_type]:
272
+ self.handlers.pop(event_type, None)
273
+ continue
274
+ if not isinstance(event_type, type):
275
+ continue
276
+ domain_mod = _cdp_get_module(event_type.__module__.rsplit(".", 1)[-1])
277
+ if domain_mod in self.enabled_domains:
278
+ if domain_mod in enabled_copy:
279
+ enabled_copy.remove(domain_mod)
280
+ continue
281
+ if domain_mod in protected:
282
+ continue
283
+ try:
284
+ self.enabled_domains.append(domain_mod)
285
+ await self.send(domain_mod.enable(), _is_update=True)
286
+ except Exception:
287
+ logger.debug("Failed to enable domain %s", domain_mod, exc_info=True)
288
+ try:
289
+ self.enabled_domains.remove(domain_mod)
290
+ except ValueError:
291
+ pass
292
+ # Remove domains that no longer have handlers, EXCEPT protected ones
293
+ for ed in enabled_copy:
294
+ if ed in protected:
295
+ continue
296
+ try:
297
+ self.enabled_domains.remove(ed)
298
+ except ValueError:
299
+ pass
300
+
301
+ def _cancel_pending(self, reason: str = "connection closed"):
302
+ """Cancel all pending transactions (e.g., when WebSocket closes)."""
303
+ for tx_id, tx in list(self._pending.items()):
304
+ if not tx.done():
305
+ tx.set_exception(ProtocolException(reason))
306
+ self._pending.clear()
307
+
308
+ async def _listener(self):
309
+ """Background task: receive messages, dispatch responses and events."""
310
+ try:
311
+ await self._listener_loop()
312
+ finally:
313
+ self._cancel_pending("WebSocket listener stopped")
314
+
315
+ async def _listener_loop(self):
316
+ while True:
317
+ try:
318
+ raw = await asyncio.wait_for(self._websocket.recv(), 0.05)
319
+ except asyncio.TimeoutError:
320
+ await asyncio.sleep(0.05)
321
+ continue
322
+ except websockets.exceptions.ConnectionClosedOK:
323
+ break
324
+ except websockets.exceptions.ConnectionClosed:
325
+ break
326
+ except Exception:
327
+ logger.info("WebSocket recv error", exc_info=True)
328
+ break
329
+
330
+ message = json.loads(raw)
331
+ if "id" in message:
332
+ # Response to a command
333
+ tx = self._pending.pop(message["id"], None)
334
+ if tx:
335
+ tx(**message)
336
+ else:
337
+ # CDP event
338
+ try:
339
+ event = cdp.util.parse_json_event(message)
340
+ except Exception:
341
+ continue
342
+ callbacks = self.handlers.get(type(event))
343
+ if not callbacks:
344
+ continue
345
+ for callback in callbacks:
346
+ try:
347
+ if iscoroutinefunction(callback):
348
+ try:
349
+ asyncio.create_task(callback(event, self))
350
+ except TypeError:
351
+ asyncio.create_task(callback(event))
352
+ else:
353
+ try:
354
+ callback(event, self)
355
+ except TypeError:
356
+ callback(event)
357
+ except Exception:
358
+ logger.warning(
359
+ "Exception in handler for %s",
360
+ type(event).__name__,
361
+ exc_info=True,
362
+ )