publicbrowser 1.0.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,24 @@
1
+ """Public Browser — Python client for Chrome browser automation.
2
+
3
+ v2: Uses the Public Browser Script API server (HTTP on port 9223).
4
+ All browser automation logic runs server-side for maximum quality.
5
+
6
+ CdpClient and CdpError are re-exported for backward compatibility
7
+ and for the cdp.py escape hatch (Story 9.9).
8
+ """
9
+
10
+ from publicbrowser.cdp import CdpClient, CdpError
11
+ from publicbrowser.chrome import Chrome
12
+ from publicbrowser.client import ScriptApiClient
13
+ from publicbrowser.escape_hatch import CdpEscapeHatch
14
+ from publicbrowser.page import Page
15
+
16
+ __version__ = "1.0.0"
17
+ __all__ = [
18
+ "Chrome",
19
+ "Page",
20
+ "ScriptApiClient",
21
+ "CdpClient",
22
+ "CdpError",
23
+ "CdpEscapeHatch",
24
+ ]
publicbrowser/cdp.py ADDED
@@ -0,0 +1,435 @@
1
+ """Minimal CDP (Chrome DevTools Protocol) client over WebSocket.
2
+
3
+ This module provides a low-level CDP client that communicates with Chrome
4
+ via the DevTools Protocol. It handles:
5
+ - WebSocket connection to a Chrome instance
6
+ - Request/response matching via message IDs
7
+ - CDP event dispatching
8
+ - Target discovery via HTTP endpoint
9
+ - Session-based routing for tab-specific commands
10
+
11
+ Usage (async):
12
+ import asyncio
13
+ from publicbrowser.cdp import CdpClient
14
+
15
+ async def main():
16
+ client = await CdpClient.connect("localhost", 9222)
17
+ result = await client.send("Runtime.evaluate", {"expression": "1+1"})
18
+ print(result) # {"result": {"type": "number", "value": 2}}
19
+ await client.close()
20
+
21
+ asyncio.run(main())
22
+
23
+ Usage (sync):
24
+ from publicbrowser.cdp import CdpClient
25
+
26
+ client = CdpClient.connect_sync("localhost", 9222)
27
+ result = client.send_sync("Runtime.evaluate", {"expression": "1+1"})
28
+ print(result) # {"result": {"type": "number", "value": 2}}
29
+ client.close_sync()
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import asyncio
35
+ import itertools
36
+ import json
37
+ import logging
38
+ import threading
39
+ from typing import Any, Callable
40
+ from urllib.request import urlopen
41
+
42
+ from websockets.asyncio.client import connect
43
+ from websockets.exceptions import ConnectionClosed
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+ # Default timeout for CDP commands (seconds)
48
+ DEFAULT_TIMEOUT = 30.0
49
+
50
+
51
+ class CdpError(Exception):
52
+ """Raised when a CDP command returns an error response."""
53
+
54
+ def __init__(
55
+ self, code: int, message: str, data: Any = None, method: str | None = None
56
+ ) -> None:
57
+ self.code = code
58
+ self.message = message
59
+ self.data = data
60
+ self.method = method
61
+ if method:
62
+ super().__init__(f"{method} failed: CDP error {code}: {message}")
63
+ else:
64
+ super().__init__(f"CDP error {code}: {message}")
65
+
66
+
67
+ class CdpClient:
68
+ """Minimal async CDP client over WebSocket.
69
+
70
+ Handles request/response matching and event dispatching.
71
+ Use `CdpClient.connect()` to create a connected instance.
72
+ """
73
+
74
+ def __init__(self, ws_url: str) -> None:
75
+ self._ws_url = ws_url
76
+ self._ws: Any = None
77
+ self._counter = itertools.count(1)
78
+ self._pending: dict[int, asyncio.Future[dict[str, Any]]] = {}
79
+ self._event_handlers: dict[str, list[Callable[[dict[str, Any]], None]]] = {}
80
+ self._listener_task: asyncio.Task[None] | None = None
81
+ self._closed = False
82
+
83
+ @classmethod
84
+ async def connect(
85
+ cls,
86
+ host: str = "localhost",
87
+ port: int = 9222,
88
+ *,
89
+ target_id: str | None = None,
90
+ ws_url: str | None = None,
91
+ ) -> CdpClient:
92
+ """Connect to a Chrome instance via CDP.
93
+
94
+ Args:
95
+ host: Chrome host (default: localhost).
96
+ port: Chrome debugging port (default: 9222).
97
+ target_id: Connect to a specific target (tab). If None, connects
98
+ to the browser-level endpoint.
99
+ ws_url: Direct WebSocket URL. If provided, host/port/target_id
100
+ are ignored.
101
+
102
+ Returns:
103
+ A connected CdpClient instance.
104
+
105
+ Raises:
106
+ ConnectionError: If Chrome is not reachable.
107
+ """
108
+ if ws_url is None:
109
+ if target_id is not None:
110
+ ws_url = f"ws://{host}:{port}/devtools/page/{target_id}"
111
+ else:
112
+ ws_url = cls._discover_browser_ws(host, port)
113
+
114
+ client = cls(ws_url)
115
+ await client._connect()
116
+ return client
117
+
118
+ @staticmethod
119
+ def _discover_browser_ws(host: str, port: int) -> str:
120
+ """Discover the browser WebSocket URL via the /json/version endpoint.
121
+
122
+ Args:
123
+ host: Chrome host.
124
+ port: Chrome debugging port.
125
+
126
+ Returns:
127
+ The browser WebSocket debugger URL.
128
+
129
+ Raises:
130
+ ConnectionError: If Chrome is not reachable or the response
131
+ is malformed.
132
+ """
133
+ url = f"http://{host}:{port}/json/version"
134
+ try:
135
+ with urlopen(url, timeout=5) as resp:
136
+ data = json.loads(resp.read())
137
+ ws_url = data.get("webSocketDebuggerUrl")
138
+ if not ws_url:
139
+ raise ConnectionError(
140
+ f"No webSocketDebuggerUrl in /json/version response from {url}"
141
+ )
142
+ return ws_url
143
+ except ConnectionError:
144
+ raise # Re-raise our own ConnectionError (missing field)
145
+ except OSError as exc:
146
+ raise ConnectionError(
147
+ f"Cannot reach Chrome at {url}. Is Chrome running with "
148
+ f"--remote-debugging-port={port}?"
149
+ ) from exc
150
+
151
+ async def _connect(self) -> None:
152
+ """Establish WebSocket connection and start listener."""
153
+ try:
154
+ # websockets.asyncio.client.connect is both an async context manager
155
+ # and directly awaitable. We use __aenter__ to get the connection
156
+ # and store the context manager for clean shutdown.
157
+ self._connect_cm = connect(
158
+ self._ws_url,
159
+ ping_interval=None, # CDP does not use WebSocket pings
160
+ max_size=64 * 1024 * 1024, # 64 MB for large DOM snapshots
161
+ )
162
+ self._ws = await self._connect_cm.__aenter__()
163
+ except OSError as exc:
164
+ raise ConnectionError(
165
+ f"WebSocket connection failed to {self._ws_url}: {exc}"
166
+ ) from exc
167
+
168
+ self._closed = False
169
+ self._listener_task = asyncio.create_task(self._listen())
170
+
171
+ async def _listen(self) -> None:
172
+ """Background listener that dispatches responses and events."""
173
+ try:
174
+ async for raw in self._ws:
175
+ try:
176
+ msg = json.loads(raw)
177
+ except json.JSONDecodeError:
178
+ logger.warning("Received non-JSON message: %s", raw[:200])
179
+ continue
180
+
181
+ # Response to a pending request
182
+ if "id" in msg and msg["id"] in self._pending:
183
+ future = self._pending.pop(msg["id"])
184
+ if not future.done():
185
+ future.set_result(msg)
186
+ continue
187
+
188
+ # CDP event (no id, has method)
189
+ method = msg.get("method")
190
+ if method and method in self._event_handlers:
191
+ params = msg.get("params", {})
192
+ for handler in self._event_handlers[method]:
193
+ try:
194
+ handler(params)
195
+ except Exception:
196
+ logger.exception(
197
+ "Error in event handler for %s", method
198
+ )
199
+ except ConnectionClosed:
200
+ logger.debug("WebSocket connection closed")
201
+ except asyncio.CancelledError:
202
+ # Map CancelledError to ConnectionError for pending futures
203
+ conn_err = ConnectionError("WebSocket connection lost")
204
+ for future in self._pending.values():
205
+ if not future.done():
206
+ future.set_exception(conn_err)
207
+ self._pending.clear()
208
+ return
209
+ finally:
210
+ # Cancel all pending futures that weren't already resolved
211
+ for future in self._pending.values():
212
+ if not future.done():
213
+ future.cancel()
214
+ self._pending.clear()
215
+
216
+ async def send(
217
+ self,
218
+ method: str,
219
+ params: dict[str, Any] | None = None,
220
+ *,
221
+ timeout: float = DEFAULT_TIMEOUT,
222
+ session_id: str | None = None,
223
+ ) -> dict[str, Any]:
224
+ """Send a CDP command and wait for the response.
225
+
226
+ Args:
227
+ method: CDP method name (e.g. "Runtime.evaluate").
228
+ params: CDP method parameters.
229
+ timeout: Maximum wait time in seconds.
230
+ session_id: Optional CDP session ID for tab-specific commands.
231
+ When provided, the request includes a ``sessionId`` field
232
+ for multiplexed session routing via Target.attachToTarget.
233
+
234
+ Returns:
235
+ The ``result`` field from the CDP response. For example,
236
+ ``Runtime.evaluate`` returns
237
+ ``{"result": {"type": "number", "value": 2}}``.
238
+
239
+ Raises:
240
+ CdpError: If CDP returns an error response.
241
+ asyncio.TimeoutError: If the response does not arrive in time.
242
+ ConnectionError: If the WebSocket is not connected.
243
+ """
244
+ if self._closed or self._ws is None:
245
+ raise ConnectionError("CdpClient is not connected")
246
+
247
+ cmd_id = next(self._counter)
248
+ loop = asyncio.get_running_loop()
249
+ future: asyncio.Future[dict[str, Any]] = loop.create_future()
250
+ self._pending[cmd_id] = future
251
+
252
+ message: dict[str, Any] = {"id": cmd_id, "method": method}
253
+ if params:
254
+ message["params"] = params
255
+ if session_id is not None:
256
+ message["sessionId"] = session_id
257
+
258
+ try:
259
+ await self._ws.send(json.dumps(message))
260
+ except ConnectionClosed as exc:
261
+ self._pending.pop(cmd_id, None)
262
+ raise ConnectionError(f"WebSocket closed while sending: {exc}") from exc
263
+
264
+ try:
265
+ response = await asyncio.wait_for(future, timeout=timeout)
266
+ except asyncio.TimeoutError:
267
+ self._pending.pop(cmd_id, None)
268
+ raise
269
+
270
+ if "error" in response:
271
+ err = response["error"]
272
+ raise CdpError(
273
+ code=err.get("code", -1),
274
+ message=err.get("message", "Unknown CDP error"),
275
+ data=err.get("data"),
276
+ method=method,
277
+ )
278
+
279
+ return response.get("result", {})
280
+
281
+ def on(self, event: str, handler: Callable[[dict[str, Any]], None]) -> None:
282
+ """Register an event handler for a CDP event.
283
+
284
+ Args:
285
+ event: CDP event name (e.g. "Page.loadEventFired").
286
+ handler: Callback that receives the event params dict.
287
+ """
288
+ self._event_handlers.setdefault(event, []).append(handler)
289
+
290
+ def off(self, event: str, handler: Callable[[dict[str, Any]], None]) -> None:
291
+ """Remove an event handler.
292
+
293
+ Args:
294
+ event: CDP event name.
295
+ handler: The handler to remove.
296
+ """
297
+ handlers = self._event_handlers.get(event, [])
298
+ if handler in handlers:
299
+ handlers.remove(handler)
300
+
301
+ async def close(self) -> None:
302
+ """Close the WebSocket connection and stop the listener."""
303
+ self._closed = True
304
+
305
+ # Cancel all pending futures before stopping the listener
306
+ for future in self._pending.values():
307
+ if not future.done():
308
+ future.cancel()
309
+ self._pending.clear()
310
+
311
+ if self._listener_task and not self._listener_task.done():
312
+ self._listener_task.cancel()
313
+ try:
314
+ await self._listener_task
315
+ except asyncio.CancelledError:
316
+ pass
317
+
318
+ if self._ws:
319
+ await self._ws.close()
320
+ self._ws = None
321
+
322
+ # ------------------------------------------------------------------
323
+ # Synchronous API — wraps async methods for non-async callers
324
+ # ------------------------------------------------------------------
325
+
326
+ @classmethod
327
+ def connect_sync(
328
+ cls,
329
+ host: str = "localhost",
330
+ port: int = 9222,
331
+ *,
332
+ target_id: str | None = None,
333
+ ws_url: str | None = None,
334
+ ) -> CdpClient:
335
+ """Synchronous version of :meth:`connect`.
336
+
337
+ Creates a new event loop in a background thread and connects to Chrome.
338
+ The returned client exposes ``send_sync()`` and ``close_sync()`` for
339
+ purely synchronous usage.
340
+
341
+ Args:
342
+ host: Chrome host (default: localhost).
343
+ port: Chrome debugging port (default: 9222).
344
+ target_id: Connect to a specific target (tab).
345
+ ws_url: Direct WebSocket URL (skips discovery).
346
+
347
+ Returns:
348
+ A connected CdpClient instance.
349
+
350
+ Raises:
351
+ ConnectionError: If Chrome is not reachable.
352
+ """
353
+ loop = asyncio.new_event_loop()
354
+ thread = threading.Thread(target=loop.run_forever, daemon=True)
355
+ thread.start()
356
+
357
+ future = asyncio.run_coroutine_threadsafe(
358
+ cls.connect(host, port, target_id=target_id, ws_url=ws_url), loop
359
+ )
360
+ client = future.result()
361
+ client._sync_loop = loop
362
+ client._sync_thread = thread
363
+ return client
364
+
365
+ def send_sync(
366
+ self,
367
+ method: str,
368
+ params: dict[str, Any] | None = None,
369
+ *,
370
+ timeout: float = DEFAULT_TIMEOUT,
371
+ session_id: str | None = None,
372
+ ) -> dict[str, Any]:
373
+ """Synchronous version of :meth:`send`.
374
+
375
+ Args:
376
+ method: CDP method name (e.g. "Runtime.evaluate").
377
+ params: CDP method parameters.
378
+ timeout: Maximum wait time in seconds.
379
+ session_id: Optional CDP session ID for tab-specific commands.
380
+
381
+ Returns:
382
+ The ``result`` field from the CDP response.
383
+
384
+ Raises:
385
+ CdpError: If CDP returns an error response.
386
+ TimeoutError: If the response does not arrive in time.
387
+ ConnectionError: If the WebSocket is not connected.
388
+ RuntimeError: If no sync event loop is available (use connect_sync first).
389
+ """
390
+ loop = getattr(self, "_sync_loop", None)
391
+ if loop is None:
392
+ raise RuntimeError(
393
+ "No sync event loop. Use CdpClient.connect_sync() or call send() with await."
394
+ )
395
+ future = asyncio.run_coroutine_threadsafe(
396
+ self.send(method, params, timeout=timeout, session_id=session_id), loop
397
+ )
398
+ return future.result(timeout=timeout + 1)
399
+
400
+ def close_sync(self) -> None:
401
+ """Synchronous version of :meth:`close`.
402
+
403
+ Closes the WebSocket connection and stops the background event loop.
404
+
405
+ Raises:
406
+ RuntimeError: If no sync event loop is available.
407
+ """
408
+ loop = getattr(self, "_sync_loop", None)
409
+ if loop is None:
410
+ raise RuntimeError(
411
+ "No sync event loop. Use CdpClient.connect_sync() or call close() with await."
412
+ )
413
+ future = asyncio.run_coroutine_threadsafe(self.close(), loop)
414
+ future.result(timeout=5)
415
+ loop.call_soon_threadsafe(loop.stop)
416
+ thread = getattr(self, "_sync_thread", None)
417
+ if thread is not None:
418
+ thread.join(timeout=5)
419
+
420
+ @property
421
+ def closed(self) -> bool:
422
+ """Whether the client is closed."""
423
+ return self._closed
424
+
425
+ async def __aenter__(self) -> CdpClient:
426
+ return self
427
+
428
+ async def __aexit__(self, *exc: Any) -> None:
429
+ await self.close()
430
+
431
+ def __enter__(self) -> CdpClient:
432
+ return self
433
+
434
+ def __exit__(self, *exc: Any) -> None:
435
+ self.close_sync()
@@ -0,0 +1,156 @@
1
+ """Chrome — entry point for browser automation (v2 — Shared Core Client).
2
+
3
+ Provides the top-level ``Chrome`` class that connects to the Public Browser
4
+ Script API server and manages tab lifecycle via ``new_page()``.
5
+
6
+ In v2, all browser automation logic runs server-side. The Python library is a
7
+ thin HTTP client that sends tool calls to the server on port 9223.
8
+
9
+ Usage::
10
+
11
+ from publicbrowser import Chrome
12
+
13
+ chrome = Chrome.connect()
14
+ with chrome.new_page() as page:
15
+ page.navigate("https://example.com")
16
+ title = page.evaluate("document.title")
17
+ print(title)
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from contextlib import contextmanager
23
+ from typing import Any, Generator
24
+
25
+ from publicbrowser.client import ScriptApiClient
26
+ from publicbrowser.page import Page
27
+
28
+
29
+ class Chrome:
30
+ """Connection to the Public Browser Script API server.
31
+
32
+ Use ``Chrome.connect()`` to create an instance. Do not instantiate directly.
33
+
34
+ The Chrome object holds an HTTP client for the Script API and can create
35
+ new tabs via ``new_page()``. If no server is running, it auto-starts one.
36
+ """
37
+
38
+ def __init__(self, client: ScriptApiClient) -> None:
39
+ self._client = client
40
+ self._closed = False
41
+
42
+ @classmethod
43
+ def connect(
44
+ cls,
45
+ host: str = "localhost",
46
+ port: int = 9223,
47
+ *,
48
+ server_path: str | None = None,
49
+ auto_start: bool = True,
50
+ profile: str | None = None,
51
+ ) -> Chrome:
52
+ """Connect to the Public Browser Script API server.
53
+
54
+ If the server is not running and ``auto_start`` is True, starts it
55
+ automatically as a subprocess.
56
+
57
+ Args:
58
+ host: Server host (default: localhost).
59
+ port: Server port (default: 9223).
60
+ server_path: Explicit path to the server binary. If not given,
61
+ the server is found via PATH or npx fallback.
62
+ auto_start: Whether to auto-start the server if not running
63
+ (default: True).
64
+ profile: Chrome profile name (e.g. "Julian", "Business").
65
+ When auto-starting, passes --profile to the server.
66
+ When connecting to a running server, calls /config/profile.
67
+
68
+ Returns:
69
+ A connected Chrome instance.
70
+
71
+ Raises:
72
+ ConnectionError: If the server is not reachable and auto_start
73
+ is False.
74
+ FileNotFoundError: If auto_start is True but no server binary
75
+ can be found.
76
+ TimeoutError: If the auto-started server does not become ready.
77
+ """
78
+ client = ScriptApiClient(host, port)
79
+
80
+ if not client._is_server_running():
81
+ if auto_start:
82
+ if profile is None:
83
+ client.start_server(server_path=server_path)
84
+ else:
85
+ client.start_server(server_path=server_path, profile=profile)
86
+ else:
87
+ raise ConnectionError(
88
+ f"Public Browser server not reachable on {host}:{port}. "
89
+ f"Start it with 'public-browser --script' or set auto_start=True."
90
+ )
91
+ elif profile:
92
+ client.configure_profile(profile)
93
+
94
+ return cls(client)
95
+
96
+ @contextmanager
97
+ def new_page(self) -> Generator[Page, None, None]:
98
+ """Create a new tab and return a Page as a context manager.
99
+
100
+ The session (tab) is automatically closed when the context manager
101
+ exits, even if an exception occurs.
102
+
103
+ Yields:
104
+ A Page instance for the new tab.
105
+
106
+ Example::
107
+
108
+ with chrome.new_page() as page:
109
+ page.navigate("https://example.com")
110
+ page.click("#button")
111
+ """
112
+ session_token, target_id, cdp_ws_url, cdp_session_id = (
113
+ self._client.create_session()
114
+ )
115
+
116
+ page = Page(
117
+ client=self._client,
118
+ session_token=session_token,
119
+ target_id=target_id,
120
+ cdp_ws_url=cdp_ws_url,
121
+ cdp_session_id=cdp_session_id,
122
+ )
123
+
124
+ try:
125
+ yield page
126
+ finally:
127
+ try:
128
+ page.close() # Close Escape Hatch WebSocket if open
129
+ except Exception:
130
+ pass
131
+ try:
132
+ self._client.close_session(session_token)
133
+ except Exception:
134
+ pass # Cleanup errors must not propagate
135
+
136
+ @property
137
+ def closed(self) -> bool:
138
+ """Whether the Chrome connection is closed."""
139
+ return self._closed
140
+
141
+ def close(self) -> None:
142
+ """Close the connection and terminate any auto-started server.
143
+
144
+ Does NOT close Chrome itself — only the HTTP client and any
145
+ auto-started server subprocess.
146
+ """
147
+ if self._closed:
148
+ return
149
+ self._closed = True
150
+ self._client.close()
151
+
152
+ def __enter__(self) -> Chrome:
153
+ return self
154
+
155
+ def __exit__(self, *exc: Any) -> None:
156
+ self.close()