cdpwave 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.
cdpwave/client.py ADDED
@@ -0,0 +1,332 @@
1
+ from __future__ import annotations
2
+
3
+ import contextlib
4
+ import logging
5
+ from typing import Any
6
+
7
+ from cdpwave.browser.discovery import TargetDiscovery, TargetInfo
8
+ from cdpwave.browser.launcher import BrowserLauncher
9
+ from cdpwave.domains.console import ConsoleDomain
10
+ from cdpwave.domains.dom import DOMDomain
11
+ from cdpwave.domains.log import LogDomain
12
+ from cdpwave.domains.network import NetworkDomain
13
+ from cdpwave.domains.page import PageDomain
14
+ from cdpwave.domains.runtime import RuntimeDomain
15
+ from cdpwave.domains.target import TargetDomain
16
+ from cdpwave.events.dispatcher import EventDispatcher
17
+ from cdpwave.events.handlers import EventHandler, Subscription
18
+ from cdpwave.exceptions import SessionClosedError
19
+ from cdpwave.session.manager import SessionManager
20
+ from cdpwave.transport.connection import Connection
21
+ from cdpwave.types import CommandSender
22
+
23
+ logger = logging.getLogger("cdpwave.client")
24
+
25
+
26
+ class CDPSession:
27
+ def __init__(
28
+ self,
29
+ connection: Connection,
30
+ session_id: str,
31
+ target_id: str,
32
+ client: CDPClient | None = None,
33
+ ) -> None:
34
+ self._connection = connection
35
+ self._session_id = session_id
36
+ self._target_id = target_id
37
+ self._closed = False
38
+ self._dispatcher = EventDispatcher()
39
+
40
+ if client is not None:
41
+ client._session_dispatchers[session_id] = self._dispatcher
42
+ self._client = client
43
+
44
+ async def _send(
45
+ method: str,
46
+ params: dict[str, Any] | None = None,
47
+ ) -> dict[str, Any]:
48
+ return await connection.send_command(
49
+ method,
50
+ params,
51
+ session_id=session_id,
52
+ )
53
+
54
+ self._sender: CommandSender = _send
55
+ self._page = PageDomain(self._sender)
56
+ self._runtime = RuntimeDomain(self._sender)
57
+ self._target = TargetDomain(self._sender)
58
+ self._network = NetworkDomain(self._sender)
59
+ self._dom = DOMDomain(self._sender)
60
+ self._log = LogDomain(self._sender)
61
+ self._console = ConsoleDomain(self._sender)
62
+
63
+ @property
64
+ def page(self) -> PageDomain:
65
+ return self._page
66
+
67
+ @property
68
+ def runtime(self) -> RuntimeDomain:
69
+ return self._runtime
70
+
71
+ @property
72
+ def target(self) -> TargetDomain:
73
+ return self._target
74
+
75
+ @property
76
+ def network(self) -> NetworkDomain:
77
+ return self._network
78
+
79
+ @property
80
+ def dom(self) -> DOMDomain:
81
+ return self._dom
82
+
83
+ @property
84
+ def log(self) -> LogDomain:
85
+ return self._log
86
+
87
+ @property
88
+ def console(self) -> ConsoleDomain:
89
+ return self._console
90
+
91
+ @property
92
+ def session_id(self) -> str:
93
+ return self._session_id
94
+
95
+ @property
96
+ def target_id(self) -> str:
97
+ return self._target_id
98
+
99
+ @property
100
+ def is_closed(self) -> bool:
101
+ return self._closed
102
+
103
+ async def send(
104
+ self,
105
+ method: str,
106
+ params: dict[str, Any] | None = None,
107
+ ) -> dict[str, Any]:
108
+ if self._closed:
109
+ raise SessionClosedError(
110
+ f"Session {self._session_id} is closed"
111
+ )
112
+ return await self._sender(method, params)
113
+
114
+ async def close(self) -> None:
115
+ if self._closed:
116
+ return
117
+ self._closed = True
118
+ self._dispatcher.clear()
119
+ if self._client is not None:
120
+ self._client._session_dispatchers.pop(self._session_id, None)
121
+ with contextlib.suppress(Exception):
122
+ await self._connection.send_command(
123
+ "Target.detachFromTarget",
124
+ {"sessionId": self._session_id},
125
+ )
126
+ logger.info("Session %s closed", self._session_id)
127
+
128
+ def on(self, event_name: str, handler: EventHandler) -> Subscription:
129
+ return self._dispatcher.on(event_name, handler)
130
+
131
+ def off(self, event_name: str, handler: EventHandler) -> None:
132
+ self._dispatcher.off(event_name, handler)
133
+
134
+ async def __aenter__(self) -> CDPSession:
135
+ return self
136
+
137
+ async def __aexit__(
138
+ self,
139
+ exc_type: object,
140
+ exc_val: object,
141
+ exc_tb: object,
142
+ ) -> None:
143
+ await self.close()
144
+
145
+
146
+ class CDPClient:
147
+ def __init__(
148
+ self,
149
+ connection: Connection,
150
+ launcher: BrowserLauncher | None = None,
151
+ discovery: TargetDiscovery | None = None,
152
+ ) -> None:
153
+ self._connection = connection
154
+ self._launcher = launcher
155
+ self._discovery = discovery
156
+ self._session_manager = SessionManager(connection)
157
+ self._dispatcher = EventDispatcher()
158
+ self._session_dispatchers: dict[str, EventDispatcher] = {}
159
+ self._sessions: dict[str, CDPSession] = {}
160
+ self._closed = False
161
+
162
+ async def _event_callback(
163
+ self,
164
+ event_name: str,
165
+ params: dict[str, Any],
166
+ session_id: str | None,
167
+ ) -> None:
168
+ if event_name == "Target.detachedFromTarget":
169
+ detached_session_id = params.get("sessionId")
170
+ if detached_session_id is not None:
171
+ session = self._sessions.get(detached_session_id)
172
+ if session is not None:
173
+ session._closed = True
174
+ session._dispatcher.clear()
175
+ self._session_dispatchers.pop(detached_session_id, None)
176
+ self._sessions.pop(detached_session_id, None)
177
+ logger.info(
178
+ "Session %s detached by browser",
179
+ detached_session_id,
180
+ )
181
+ else:
182
+ logger.warning(
183
+ "Target.detachedFromTarget for unknown session %s",
184
+ detached_session_id,
185
+ )
186
+ return
187
+
188
+ if session_id is None:
189
+ await self._dispatcher.dispatch(event_name, params)
190
+ else:
191
+ dispatcher = self._session_dispatchers.get(session_id)
192
+ if dispatcher is not None:
193
+ await dispatcher.dispatch(event_name, params)
194
+ else:
195
+ logger.warning(
196
+ "Event %s for unknown session %s",
197
+ event_name,
198
+ session_id,
199
+ )
200
+
201
+ def on(self, event_name: str, handler: EventHandler) -> Subscription:
202
+ return self._dispatcher.on(event_name, handler)
203
+
204
+ def off(self, event_name: str, handler: EventHandler) -> None:
205
+ self._dispatcher.off(event_name, handler)
206
+
207
+ @classmethod
208
+ async def launch(
209
+ cls,
210
+ headless: bool = True,
211
+ browser_path: str | None = None,
212
+ port: int = 0,
213
+ user_data_dir: str | None = None,
214
+ extra_args: list[str] | None = None,
215
+ timeout: float = 10.0,
216
+ ) -> CDPClient:
217
+ launcher = BrowserLauncher(
218
+ browser_path=browser_path,
219
+ port=port,
220
+ headless=headless,
221
+ user_data_dir=user_data_dir,
222
+ extra_args=extra_args,
223
+ )
224
+ info = await launcher.launch(timeout=timeout)
225
+ discovery = TargetDiscovery(port=info.port)
226
+ client = cls.__new__(cls)
227
+ client._connection = Connection(
228
+ info.web_socket_debugger_url,
229
+ event_callback=client._event_callback,
230
+ )
231
+ await client._connection.connect()
232
+ client._launcher = launcher
233
+ client._discovery = discovery
234
+ client._session_manager = SessionManager(client._connection)
235
+ client._dispatcher = EventDispatcher()
236
+ client._session_dispatchers = {}
237
+ client._sessions = {}
238
+ client._closed = False
239
+ return client
240
+
241
+ @classmethod
242
+ async def connect(
243
+ cls,
244
+ host: str = "localhost",
245
+ port: int = 9222,
246
+ ) -> CDPClient:
247
+ discovery = TargetDiscovery(host=host, port=port)
248
+ version = await discovery.get_version()
249
+ client = cls.__new__(cls)
250
+ client._connection = Connection(
251
+ version.web_socket_debugger_url,
252
+ event_callback=client._event_callback,
253
+ )
254
+ await client._connection.connect()
255
+ client._launcher = None
256
+ client._discovery = discovery
257
+ client._session_manager = SessionManager(client._connection)
258
+ client._dispatcher = EventDispatcher()
259
+ client._session_dispatchers = {}
260
+ client._sessions = {}
261
+ client._closed = False
262
+ return client
263
+
264
+ async def new_page(self, url: str = "about:blank") -> CDPSession:
265
+ target_id = await self._session_manager.create_target(url)
266
+ session_id = await self._session_manager.attach_to_target(target_id)
267
+ session = CDPSession(
268
+ connection=self._connection,
269
+ session_id=session_id,
270
+ target_id=target_id,
271
+ client=self,
272
+ )
273
+ self._sessions[session_id] = session
274
+ return session
275
+
276
+ async def get_pages(self) -> list[TargetInfo]:
277
+ if self._discovery is None:
278
+ raise RuntimeError("Discovery is not available")
279
+ targets = await self._discovery.list_targets()
280
+ return [t for t in targets if t.type == "page"]
281
+
282
+ async def connect_to_page(self, target_id: str) -> CDPSession:
283
+ session_id = await self._session_manager.attach_to_target(target_id)
284
+ session = CDPSession(
285
+ connection=self._connection,
286
+ session_id=session_id,
287
+ target_id=target_id,
288
+ client=self,
289
+ )
290
+ self._sessions[session_id] = session
291
+ return session
292
+
293
+ async def close(self) -> None:
294
+ if self._closed:
295
+ return
296
+ self._closed = True
297
+
298
+ for session in list(self._sessions.values()):
299
+ with contextlib.suppress(Exception):
300
+ session._closed = True
301
+ session._dispatcher.clear()
302
+ self._session_dispatchers.pop(session._session_id, None)
303
+ self._sessions.clear()
304
+ self._dispatcher.clear()
305
+
306
+ with contextlib.suppress(Exception):
307
+ await self._connection.close()
308
+
309
+ if self._launcher is not None:
310
+ with contextlib.suppress(Exception):
311
+ await self._launcher.close()
312
+
313
+ logger.info("CDPClient closed")
314
+
315
+ @property
316
+ def is_closed(self) -> bool:
317
+ return self._closed or self._connection.is_closed
318
+
319
+ @property
320
+ def is_connected(self) -> bool:
321
+ return not self._connection.is_closed
322
+
323
+ async def __aenter__(self) -> CDPClient:
324
+ return self
325
+
326
+ async def __aexit__(
327
+ self,
328
+ exc_type: object,
329
+ exc_val: object,
330
+ exc_tb: object,
331
+ ) -> None:
332
+ await self.close()
File without changes
@@ -0,0 +1,15 @@
1
+ from typing import Any
2
+
3
+ from cdpwave.types import CommandSender
4
+
5
+
6
+ class BaseDomain:
7
+ def __init__(self, send: CommandSender) -> None:
8
+ self._send = send
9
+
10
+ async def _call(
11
+ self,
12
+ method: str,
13
+ params: dict[str, Any] | None = None,
14
+ ) -> dict[str, Any]:
15
+ return await self._send(method, params)
@@ -0,0 +1,14 @@
1
+ from typing import Any
2
+
3
+ from cdpwave.domains.base import BaseDomain
4
+
5
+
6
+ class ConsoleDomain(BaseDomain):
7
+ async def enable(self) -> dict[str, Any]:
8
+ return await self._call("Console.enable")
9
+
10
+ async def disable(self) -> dict[str, Any]:
11
+ return await self._call("Console.disable")
12
+
13
+ async def clear_messages(self) -> dict[str, Any]:
14
+ return await self._call("Console.clearMessages")
cdpwave/domains/dom.py ADDED
@@ -0,0 +1,92 @@
1
+ from typing import Any
2
+
3
+ from cdpwave.domains.base import BaseDomain
4
+
5
+
6
+ class DOMDomain(BaseDomain):
7
+ async def enable(self) -> dict[str, Any]:
8
+ return await self._call("DOM.enable")
9
+
10
+ async def disable(self) -> dict[str, Any]:
11
+ return await self._call("DOM.disable")
12
+
13
+ async def get_document(
14
+ self,
15
+ depth: int = -1,
16
+ pierce: bool = False,
17
+ ) -> dict[str, Any]:
18
+ return await self._call(
19
+ "DOM.getDocument",
20
+ {"depth": depth, "pierce": pierce},
21
+ )
22
+
23
+ async def get_outer_html(self, node_id: int) -> dict[str, Any]:
24
+ return await self._call(
25
+ "DOM.getOuterHTML",
26
+ {"nodeId": node_id},
27
+ )
28
+
29
+ async def get_inner_html(self, node_id: int) -> dict[str, Any]:
30
+ return await self._call(
31
+ "DOM.getInnerHTML",
32
+ {"nodeId": node_id},
33
+ )
34
+
35
+ async def query_selector(
36
+ self,
37
+ node_id: int,
38
+ selector: str,
39
+ ) -> dict[str, Any]:
40
+ return await self._call(
41
+ "DOM.querySelector",
42
+ {"nodeId": node_id, "selector": selector},
43
+ )
44
+
45
+ async def query_selector_all(
46
+ self,
47
+ node_id: int,
48
+ selector: str,
49
+ ) -> dict[str, Any]:
50
+ return await self._call(
51
+ "DOM.querySelectorAll",
52
+ {"nodeId": node_id, "selector": selector},
53
+ )
54
+
55
+ async def remove_node(self, node_id: int) -> dict[str, Any]:
56
+ return await self._call(
57
+ "DOM.removeNode",
58
+ {"nodeId": node_id},
59
+ )
60
+
61
+ async def set_attribute_value(
62
+ self,
63
+ node_id: int,
64
+ name: str,
65
+ value: str,
66
+ ) -> dict[str, Any]:
67
+ return await self._call(
68
+ "DOM.setAttributeValue",
69
+ {"nodeId": node_id, "name": name, "value": value},
70
+ )
71
+
72
+ async def get_attribute(
73
+ self,
74
+ node_id: int,
75
+ name: str,
76
+ ) -> dict[str, Any]:
77
+ return await self._call(
78
+ "DOM.getAttributes",
79
+ {"nodeId": node_id},
80
+ )
81
+
82
+ async def focus(self, node_id: int) -> dict[str, Any]:
83
+ return await self._call(
84
+ "DOM.focus",
85
+ {"nodeId": node_id},
86
+ )
87
+
88
+ async def scroll_into_view_if_needed(self, node_id: int) -> dict[str, Any]:
89
+ return await self._call(
90
+ "DOM.scrollIntoViewIfNeeded",
91
+ {"nodeId": node_id},
92
+ )
cdpwave/domains/log.py ADDED
@@ -0,0 +1,26 @@
1
+ from typing import Any
2
+
3
+ from cdpwave.domains.base import BaseDomain
4
+
5
+
6
+ class LogDomain(BaseDomain):
7
+ async def enable(self) -> dict[str, Any]:
8
+ return await self._call("Log.enable")
9
+
10
+ async def disable(self) -> dict[str, Any]:
11
+ return await self._call("Log.disable")
12
+
13
+ async def clear(self) -> dict[str, Any]:
14
+ return await self._call("Log.clear")
15
+
16
+ async def start_violation_report(
17
+ self,
18
+ config: list[dict[str, Any]],
19
+ ) -> dict[str, Any]:
20
+ return await self._call(
21
+ "Log.startViolationsReport",
22
+ {"config": config},
23
+ )
24
+
25
+ async def stop_violation_report(self) -> dict[str, Any]:
26
+ return await self._call("Log.stopViolationsReport")
@@ -0,0 +1,141 @@
1
+ from typing import Any
2
+
3
+ from cdpwave.domains.base import BaseDomain
4
+
5
+
6
+ class NetworkDomain(BaseDomain):
7
+ async def enable(
8
+ self,
9
+ max_total_buffer_size: int | None = None,
10
+ max_resource_buffer_size: int | None = None,
11
+ max_post_data_size: int | None = None,
12
+ ) -> dict[str, Any]:
13
+ params: dict[str, Any] = {}
14
+ if max_total_buffer_size is not None:
15
+ params["maxTotalBufferSize"] = max_total_buffer_size
16
+ if max_resource_buffer_size is not None:
17
+ params["maxResourceBufferSize"] = max_resource_buffer_size
18
+ if max_post_data_size is not None:
19
+ params["maxPostDataSize"] = max_post_data_size
20
+ return await self._call("Network.enable", params or None)
21
+
22
+ async def disable(self) -> dict[str, Any]:
23
+ return await self._call("Network.disable")
24
+
25
+ async def set_user_agent_override(
26
+ self,
27
+ user_agent: str,
28
+ accept_language: str | None = None,
29
+ platform: str | None = None,
30
+ ) -> dict[str, Any]:
31
+ params: dict[str, Any] = {"userAgent": user_agent}
32
+ if accept_language is not None:
33
+ params["acceptLanguage"] = accept_language
34
+ if platform is not None:
35
+ params["platform"] = platform
36
+ return await self._call("Network.setUserAgentOverride", params)
37
+
38
+ async def set_extra_request_headers(
39
+ self,
40
+ headers: dict[str, str],
41
+ ) -> dict[str, Any]:
42
+ return await self._call(
43
+ "Network.setExtraRequestHeaders",
44
+ {"headers": headers},
45
+ )
46
+
47
+ async def clear_browser_cookies(self) -> dict[str, Any]:
48
+ return await self._call("Network.clearBrowserCookies")
49
+
50
+ async def clear_browser_cache(self) -> dict[str, Any]:
51
+ return await self._call("Network.clearBrowserCache")
52
+
53
+ async def get_cookies(
54
+ self,
55
+ urls: list[str] | None = None,
56
+ ) -> dict[str, Any]:
57
+ params: dict[str, Any] = {}
58
+ if urls is not None:
59
+ params["urls"] = urls
60
+ return await self._call("Network.getCookies", params or None)
61
+
62
+ async def set_cookie(
63
+ self,
64
+ name: str,
65
+ value: str,
66
+ url: str | None = None,
67
+ domain: str | None = None,
68
+ path: str | None = None,
69
+ secure: bool = False,
70
+ http_only: bool = False,
71
+ same_site: str | None = None,
72
+ expires: float | None = None,
73
+ ) -> dict[str, Any]:
74
+ params: dict[str, Any] = {
75
+ "name": name,
76
+ "value": value,
77
+ "secure": secure,
78
+ "httpOnly": http_only,
79
+ }
80
+ if url is not None:
81
+ params["url"] = url
82
+ if domain is not None:
83
+ params["domain"] = domain
84
+ if path is not None:
85
+ params["path"] = path
86
+ if same_site is not None:
87
+ params["sameSite"] = same_site
88
+ if expires is not None:
89
+ params["expires"] = expires
90
+ return await self._call("Network.setCookie", params)
91
+
92
+ async def delete_cookies(
93
+ self,
94
+ name: str,
95
+ url: str | None = None,
96
+ domain: str | None = None,
97
+ path: str | None = None,
98
+ ) -> dict[str, Any]:
99
+ params: dict[str, Any] = {"name": name}
100
+ if url is not None:
101
+ params["url"] = url
102
+ if domain is not None:
103
+ params["domain"] = domain
104
+ if path is not None:
105
+ params["path"] = path
106
+ return await self._call("Network.deleteCookies", params)
107
+
108
+ async def get_response_body(
109
+ self,
110
+ request_id: str,
111
+ ) -> dict[str, Any]:
112
+ return await self._call(
113
+ "Network.getResponseBody",
114
+ {"requestId": request_id},
115
+ )
116
+
117
+ async def set_cache_disabled(
118
+ self,
119
+ cache_disabled: bool,
120
+ ) -> dict[str, Any]:
121
+ return await self._call(
122
+ "Network.setCacheDisabled",
123
+ {"cacheDisabled": cache_disabled},
124
+ )
125
+
126
+ async def emulate_network_conditions(
127
+ self,
128
+ offline: bool = False,
129
+ latency: int = 0,
130
+ download_throughput: float = -1,
131
+ upload_throughput: float = -1,
132
+ ) -> dict[str, Any]:
133
+ return await self._call(
134
+ "Network.emulateNetworkConditions",
135
+ {
136
+ "offline": offline,
137
+ "latency": latency,
138
+ "downloadThroughput": download_throughput,
139
+ "uploadThroughput": upload_throughput,
140
+ },
141
+ )