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/__init__.py ADDED
@@ -0,0 +1,34 @@
1
+ __version__ = "0.1.0"
2
+
3
+ from cdpwave.client import CDPClient, CDPSession
4
+ from cdpwave.events.dispatcher import EventDispatcher
5
+ from cdpwave.events.handlers import EventHandler, Subscription
6
+ from cdpwave.exceptions import (
7
+ BrowserNotFoundError,
8
+ CDPError,
9
+ CommandError,
10
+ CommandTimeoutError,
11
+ ConnectionClosedError,
12
+ DiscoveryError,
13
+ LaunchError,
14
+ LaunchTimeoutError,
15
+ SessionClosedError,
16
+ )
17
+
18
+ __all__ = [
19
+ "CDPClient",
20
+ "CDPSession",
21
+ "CDPError",
22
+ "ConnectionClosedError",
23
+ "CommandError",
24
+ "CommandTimeoutError",
25
+ "BrowserNotFoundError",
26
+ "SessionClosedError",
27
+ "DiscoveryError",
28
+ "EventDispatcher",
29
+ "Subscription",
30
+ "EventHandler",
31
+ "LaunchError",
32
+ "LaunchTimeoutError",
33
+ "__version__",
34
+ ]
File without changes
@@ -0,0 +1,86 @@
1
+ import asyncio
2
+ import json
3
+ import urllib.request
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class TargetInfo:
10
+ target_id: str
11
+ type: str
12
+ title: str
13
+ url: str
14
+ web_socket_debugger_url: str | None
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class VersionInfo:
19
+ browser: str
20
+ protocol_version: str
21
+ user_agent: str
22
+ web_socket_debugger_url: str
23
+
24
+
25
+ def _http_get(url: str) -> Any:
26
+ with urllib.request.urlopen(url, timeout=10) as resp:
27
+ data = json.loads(resp.read().decode("utf-8"))
28
+ return data
29
+
30
+
31
+ def _http_put(url: str) -> Any:
32
+ req = urllib.request.Request(url, method="PUT")
33
+ with urllib.request.urlopen(req, timeout=10) as resp:
34
+ data = json.loads(resp.read().decode("utf-8"))
35
+ return data
36
+
37
+
38
+ class TargetDiscovery:
39
+ def __init__(self, host: str = "localhost", port: int = 9222) -> None:
40
+ self._base_url = f"http://{host}:{port}"
41
+
42
+ async def get_version(self) -> VersionInfo:
43
+ data: dict[str, Any] = await asyncio.to_thread(
44
+ _http_get, f"{self._base_url}/json/version"
45
+ )
46
+ return VersionInfo(
47
+ browser=str(data.get("Browser", "")),
48
+ protocol_version=str(data.get("Protocol-Version", "")),
49
+ user_agent=str(data.get("User-Agent", "")),
50
+ web_socket_debugger_url=str(data.get("webSocketDebuggerUrl", "")),
51
+ )
52
+
53
+ async def list_targets(self) -> list[TargetInfo]:
54
+ data: list[dict[str, Any]] = await asyncio.to_thread(
55
+ _http_get, f"{self._base_url}/json/list"
56
+ )
57
+ targets: list[TargetInfo] = []
58
+ for item in data:
59
+ targets.append(
60
+ TargetInfo(
61
+ target_id=str(item.get("id", "")),
62
+ type=str(item.get("type", "")),
63
+ title=str(item.get("title", "")),
64
+ url=str(item.get("url", "")),
65
+ web_socket_debugger_url=item.get("webSocketDebuggerUrl"),
66
+ )
67
+ )
68
+ return targets
69
+
70
+ async def new_tab(self, url: str = "about:blank") -> TargetInfo:
71
+ data: dict[str, Any] = await asyncio.to_thread(
72
+ _http_put, f"{self._base_url}/json/new?{url}"
73
+ )
74
+ return TargetInfo(
75
+ target_id=str(data.get("id", "")),
76
+ type=str(data.get("type", "")),
77
+ title=str(data.get("title", "")),
78
+ url=str(data.get("url", "")),
79
+ web_socket_debugger_url=data.get("webSocketDebuggerUrl"),
80
+ )
81
+
82
+ async def activate_tab(self, target_id: str) -> None:
83
+ await asyncio.to_thread(_http_get, f"{self._base_url}/json/activate/{target_id}")
84
+
85
+ async def close_tab(self, target_id: str) -> None:
86
+ await asyncio.to_thread(_http_get, f"{self._base_url}/json/close/{target_id}")
@@ -0,0 +1,130 @@
1
+ import os
2
+ import shutil
3
+ import sys
4
+
5
+ from cdpwave.exceptions import BrowserNotFoundError
6
+ from cdpwave.types import BrowserType
7
+
8
+ _WIN_CHROME_PATHS = [
9
+ r"C:\Program Files\Google\Chrome\Application\chrome.exe",
10
+ r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
11
+ ]
12
+
13
+ _WIN_EDGE_PATHS = [
14
+ r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
15
+ r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
16
+ ]
17
+
18
+ _WIN_BRAVE_PATHS = [
19
+ os.path.expandvars(r"%LOCALAPPDATA%\BraveSoftware\Brave-Browser\Application\brave.exe"),
20
+ r"C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe",
21
+ r"C:\Program Files (x86)\BraveSoftware\Brave-Browser\Application\brave.exe",
22
+ ]
23
+
24
+ _WIN_CHROMIUM_PATHS = [
25
+ r"C:\Program Files\Chromium\Application\chromium.exe",
26
+ r"C:\Program Files (x86)\Chromium\Application\chromium.exe",
27
+ ]
28
+
29
+ _MAC_CHROME_PATH = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
30
+ _MAC_EDGE_PATH = "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"
31
+ _MAC_BRAVE_PATH = "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"
32
+ _MAC_CHROMIUM_PATH = "/Applications/Chromium.app/Contents/MacOS/Chromium"
33
+
34
+ _LINUX_NAMES: dict[str, list[str]] = {
35
+ "chrome": ["google-chrome", "google-chrome-stable", "chrome"],
36
+ "edge": ["microsoft-edge", "microsoft-edge-stable"],
37
+ "brave": ["brave-browser", "brave"],
38
+ "chromium": ["chromium", "chromium-browser"],
39
+ }
40
+
41
+
42
+ def _check_paths(paths: list[str]) -> str | None:
43
+ for path in paths:
44
+ if os.path.isfile(path):
45
+ return path
46
+ return None
47
+
48
+
49
+ def _find_on_linux(names: list[str]) -> str | None:
50
+ for name in names:
51
+ path = shutil.which(name)
52
+ if path:
53
+ return path
54
+ return None
55
+
56
+
57
+ def find_chrome() -> str | None:
58
+ env_path = os.environ.get("CDPWAVE_CHROME_PATH")
59
+ if env_path and os.path.isfile(env_path):
60
+ return env_path
61
+ if sys.platform == "win32":
62
+ return _check_paths(_WIN_CHROME_PATHS)
63
+ if sys.platform == "darwin":
64
+ return _check_paths([_MAC_CHROME_PATH])
65
+ return _find_on_linux(_LINUX_NAMES["chrome"])
66
+
67
+
68
+ def find_edge() -> str | None:
69
+ env_path = os.environ.get("CDPWAVE_EDGE_PATH")
70
+ if env_path and os.path.isfile(env_path):
71
+ return env_path
72
+ if sys.platform == "win32":
73
+ return _check_paths(_WIN_EDGE_PATHS)
74
+ if sys.platform == "darwin":
75
+ return _check_paths([_MAC_EDGE_PATH])
76
+ return _find_on_linux(_LINUX_NAMES["edge"])
77
+
78
+
79
+ def find_brave() -> str | None:
80
+ env_path = os.environ.get("CDPWAVE_BRAVE_PATH")
81
+ if env_path and os.path.isfile(env_path):
82
+ return env_path
83
+ if sys.platform == "win32":
84
+ return _check_paths(_WIN_BRAVE_PATHS)
85
+ if sys.platform == "darwin":
86
+ return _check_paths([_MAC_BRAVE_PATH])
87
+ return _find_on_linux(_LINUX_NAMES["brave"])
88
+
89
+
90
+ def find_chromium() -> str | None:
91
+ env_path = os.environ.get("CDPWAVE_CHROMIUM_PATH")
92
+ if env_path and os.path.isfile(env_path):
93
+ return env_path
94
+ if sys.platform == "win32":
95
+ return _check_paths(_WIN_CHROMIUM_PATHS)
96
+ if sys.platform == "darwin":
97
+ return _check_paths([_MAC_CHROMIUM_PATH])
98
+ return _find_on_linux(_LINUX_NAMES["chromium"])
99
+
100
+
101
+ _FINDER_NAMES: dict[BrowserType, str] = {
102
+ "chrome": "find_chrome",
103
+ "edge": "find_edge",
104
+ "brave": "find_brave",
105
+ "chromium": "find_chromium",
106
+ }
107
+
108
+ _SEARCH_ORDER: list[BrowserType] = ["chrome", "edge", "brave", "chromium"]
109
+
110
+
111
+ def find_browser(preferred: BrowserType | None = None) -> str:
112
+ env_override = os.environ.get("CDPWAVE_BROWSER_PATH")
113
+ if env_override and os.path.isfile(env_override):
114
+ return env_override
115
+
116
+ search_order: list[BrowserType] = []
117
+ if preferred is not None:
118
+ search_order.append(preferred)
119
+ search_order.extend([b for b in _SEARCH_ORDER if b != preferred])
120
+
121
+ for browser_type in search_order:
122
+ finder = globals()[_FINDER_NAMES[browser_type]]
123
+ path: str | None = finder()
124
+ if path:
125
+ return path
126
+
127
+ raise BrowserNotFoundError(
128
+ "No Chromium-based browser found."
129
+ " Set CDPWAVE_BROWSER_PATH or install Chrome/Edge/Brave/Chromium."
130
+ )
@@ -0,0 +1,170 @@
1
+ import asyncio
2
+ import contextlib
3
+ import json
4
+ import os
5
+ import shutil
6
+ import socket
7
+ import tempfile
8
+ import urllib.request
9
+ from dataclasses import dataclass
10
+
11
+ from cdpwave.browser.finder import find_browser
12
+ from cdpwave.exceptions import LaunchError, LaunchTimeoutError
13
+
14
+ _DEFAULT_FLAGS = [
15
+ "--no-first-run",
16
+ "--no-default-browser-check",
17
+ "--disable-features=Translate",
18
+ ]
19
+
20
+ _CI_ENV_VARS = ["CI", "GITHUB_ACTIONS", "GITLAB_CI", "JENKINS_URL"]
21
+
22
+
23
+ def _is_ci() -> bool:
24
+ return any(os.environ.get(var) for var in _CI_ENV_VARS)
25
+
26
+
27
+ def _find_free_port() -> int:
28
+ with socket.socket() as s:
29
+ s.bind(("127.0.0.1", 0))
30
+ port: int = s.getsockname()[1]
31
+ return port
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class BrowserInfo:
36
+ web_socket_debugger_url: str
37
+ browser_version: str
38
+ protocol_version: str
39
+ user_agent: str
40
+ port: int
41
+
42
+
43
+ class BrowserLauncher:
44
+ def __init__(
45
+ self,
46
+ browser_path: str | None = None,
47
+ port: int = 0,
48
+ headless: bool = True,
49
+ user_data_dir: str | None = None,
50
+ extra_args: list[str] | None = None,
51
+ ) -> None:
52
+ self._browser_path = browser_path
53
+ self._port = port
54
+ self._headless = headless
55
+ self._user_data_dir = user_data_dir
56
+ self._extra_args = extra_args
57
+ self._process: asyncio.subprocess.Process | None = None
58
+ self._temp_dir: str | None = None
59
+ self._info: BrowserInfo | None = None
60
+
61
+ def _build_args(self) -> list[str]:
62
+ if self._browser_path is None:
63
+ self._browser_path = find_browser()
64
+
65
+ port = self._port if self._port != 0 else _find_free_port()
66
+ self._port = port
67
+
68
+ user_data_dir = self._user_data_dir
69
+ if user_data_dir is None:
70
+ user_data_dir = self._create_temp_user_dir()
71
+ self._user_data_dir = user_data_dir
72
+
73
+ args = [
74
+ self._browser_path,
75
+ f"--remote-debugging-port={port}",
76
+ f"--user-data-dir={user_data_dir}",
77
+ *_DEFAULT_FLAGS,
78
+ ]
79
+
80
+ if self._headless:
81
+ args.append("--headless=new")
82
+
83
+ if _is_ci():
84
+ args.append("--no-sandbox")
85
+
86
+ if self._extra_args:
87
+ args.extend(self._extra_args)
88
+
89
+ args.append("about:blank")
90
+ return args
91
+
92
+ def _create_temp_user_dir(self) -> str:
93
+ self._temp_dir = tempfile.mkdtemp(prefix="cdpwave-")
94
+ return self._temp_dir
95
+
96
+ async def launch(self, timeout: float = 10.0) -> BrowserInfo:
97
+ if self._process is not None:
98
+ raise RuntimeError("Browser is already running")
99
+
100
+ args = self._build_args()
101
+
102
+ self._process = await asyncio.create_subprocess_exec(
103
+ *args,
104
+ stdout=asyncio.subprocess.DEVNULL,
105
+ stderr=asyncio.subprocess.DEVNULL,
106
+ )
107
+
108
+ self._info = await self._wait_for_endpoint(timeout=timeout)
109
+ return self._info
110
+
111
+ async def _wait_for_endpoint(self, timeout: float = 10.0) -> BrowserInfo:
112
+ url = f"http://localhost:{self._port}/json/version"
113
+ delay = 0.1
114
+ elapsed = 0.0
115
+
116
+ while elapsed < timeout:
117
+ if self._process is not None and self._process.returncode is not None:
118
+ raise LaunchError(
119
+ f"Browser process exited with code {self._process.returncode}"
120
+ )
121
+
122
+ try:
123
+ data = await asyncio.to_thread(_fetch_version, url)
124
+ return BrowserInfo(
125
+ web_socket_debugger_url=str(data.get("webSocketDebuggerUrl", "")),
126
+ browser_version=str(data.get("Browser", "")),
127
+ protocol_version=str(data.get("Protocol-Version", "")),
128
+ user_agent=str(data.get("User-Agent", "")),
129
+ port=self._port,
130
+ )
131
+ except Exception:
132
+ await asyncio.sleep(delay)
133
+ elapsed += delay
134
+ delay = min(delay * 1.5, 1.0)
135
+
136
+ raise LaunchTimeoutError(
137
+ f"Browser did not become ready within {timeout}s on port {self._port}"
138
+ )
139
+
140
+ async def close(self) -> None:
141
+ if self._process is not None:
142
+ with contextlib.suppress(Exception):
143
+ if self._process.returncode is None:
144
+ self._process.terminate()
145
+ try:
146
+ await asyncio.wait_for(self._process.wait(), timeout=2.0)
147
+ except TimeoutError:
148
+ self._process.kill()
149
+ await self._process.wait()
150
+ self._process = None
151
+
152
+ if self._temp_dir is not None:
153
+ shutil.rmtree(self._temp_dir, ignore_errors=True)
154
+ self._temp_dir = None
155
+
156
+ self._info = None
157
+
158
+ @property
159
+ def is_running(self) -> bool:
160
+ return self._process is not None and self._process.returncode is None
161
+
162
+ @property
163
+ def info(self) -> BrowserInfo | None:
164
+ return self._info
165
+
166
+
167
+ def _fetch_version(url: str) -> dict[str, object]:
168
+ with urllib.request.urlopen(url, timeout=5) as resp:
169
+ data = json.loads(resp.read().decode("utf-8"))
170
+ return data # type: ignore[no-any-return]