browsercli 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.
- browsercli/__init__.py +26 -0
- browsercli/client.py +630 -0
- browsercli/exceptions.py +69 -0
- browsercli-1.0.0.dist-info/METADATA +191 -0
- browsercli-1.0.0.dist-info/RECORD +7 -0
- browsercli-1.0.0.dist-info/WHEEL +5 -0
- browsercli-1.0.0.dist-info/top_level.txt +1 -0
browsercli/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""browsercli Python client — zero-dependency wrapper for the Unix socket RPC API."""
|
|
2
|
+
|
|
3
|
+
from browsercli.client import BrowserCLI
|
|
4
|
+
from browsercli.exceptions import (
|
|
5
|
+
BrowserCLIError,
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
BadRequestError,
|
|
8
|
+
ConnectionError,
|
|
9
|
+
NotFoundError,
|
|
10
|
+
RPCError,
|
|
11
|
+
ServerError,
|
|
12
|
+
SessionError,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"BrowserCLI",
|
|
17
|
+
"BrowserCLIError",
|
|
18
|
+
"AuthenticationError",
|
|
19
|
+
"BadRequestError",
|
|
20
|
+
"ConnectionError",
|
|
21
|
+
"NotFoundError",
|
|
22
|
+
"RPCError",
|
|
23
|
+
"ServerError",
|
|
24
|
+
"SessionError",
|
|
25
|
+
]
|
|
26
|
+
__version__ = "0.4.0"
|
browsercli/client.py
ADDED
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
"""Core client that talks to the browsercli daemon over its RPC API.
|
|
2
|
+
|
|
3
|
+
On macOS/Linux the daemon listens on a Unix socket; on Windows it uses
|
|
4
|
+
TCP localhost. The transport is chosen automatically based on the session
|
|
5
|
+
file contents.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import http.client
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import socket
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Dict, List, Optional, Union
|
|
19
|
+
|
|
20
|
+
from browsercli.exceptions import (
|
|
21
|
+
BrowserCLIError,
|
|
22
|
+
AuthenticationError,
|
|
23
|
+
BadRequestError,
|
|
24
|
+
ConnectionError,
|
|
25
|
+
NotFoundError,
|
|
26
|
+
RPCError,
|
|
27
|
+
ServerError,
|
|
28
|
+
SessionError,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger("browsercli")
|
|
32
|
+
|
|
33
|
+
# Valid values for dom_query / dom_all ``mode`` parameter.
|
|
34
|
+
DOM_MODES = frozenset({"outer_html", "text"})
|
|
35
|
+
|
|
36
|
+
# Valid values for dom_wait ``state`` parameter.
|
|
37
|
+
WAIT_STATES = frozenset({"visible", "hidden", "attached", "detached"})
|
|
38
|
+
|
|
39
|
+
# Valid values for console ``level`` filter.
|
|
40
|
+
CONSOLE_LEVELS = frozenset({"", "log", "warn", "error", "info"})
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class _UnixHTTPConnection(http.client.HTTPConnection):
|
|
44
|
+
"""HTTPConnection subclass that connects to a Unix domain socket."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, socket_path: str, timeout: float = 30.0) -> None:
|
|
47
|
+
# host is unused but required by HTTPConnection
|
|
48
|
+
super().__init__("localhost", timeout=timeout)
|
|
49
|
+
self._socket_path = socket_path
|
|
50
|
+
|
|
51
|
+
def connect(self) -> None:
|
|
52
|
+
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
53
|
+
self.sock.settimeout(self.timeout)
|
|
54
|
+
self.sock.connect(self._socket_path)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class BrowserCLI:
|
|
58
|
+
"""Client for a running browsercli daemon.
|
|
59
|
+
|
|
60
|
+
Usage::
|
|
61
|
+
|
|
62
|
+
from browsercli import BrowserCLI
|
|
63
|
+
|
|
64
|
+
ac = BrowserCLI.connect() # reads ~/.browsercli/session.json
|
|
65
|
+
print(ac.status())
|
|
66
|
+
ac.goto("/")
|
|
67
|
+
text = ac.dom_query("h1", mode="text")
|
|
68
|
+
ac.stop()
|
|
69
|
+
|
|
70
|
+
All methods are synchronous and block until the daemon responds.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
socket_path: str = "",
|
|
76
|
+
token: str = "",
|
|
77
|
+
timeout: float = 30.0,
|
|
78
|
+
*,
|
|
79
|
+
rpc_port: int = 0,
|
|
80
|
+
) -> None:
|
|
81
|
+
if not socket_path and not rpc_port:
|
|
82
|
+
raise ValueError("must provide either socket_path or rpc_port")
|
|
83
|
+
if not token:
|
|
84
|
+
raise ValueError("token must be a non-empty string")
|
|
85
|
+
if timeout <= 0:
|
|
86
|
+
raise ValueError("timeout must be positive")
|
|
87
|
+
self._socket_path = socket_path
|
|
88
|
+
self._rpc_port = rpc_port
|
|
89
|
+
self._token = token
|
|
90
|
+
self._timeout = timeout
|
|
91
|
+
|
|
92
|
+
# ------------------------------------------------------------------
|
|
93
|
+
# Factory
|
|
94
|
+
# ------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def connect(
|
|
98
|
+
cls,
|
|
99
|
+
session_path: Optional[str] = None,
|
|
100
|
+
timeout: float = 30.0,
|
|
101
|
+
) -> BrowserCLI:
|
|
102
|
+
"""Create a client by reading the daemon's session file.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
session_path: Explicit path to ``session.json``. Defaults to
|
|
106
|
+
``~/.browsercli/session.json`` on macOS/Linux, or
|
|
107
|
+
``%LOCALAPPDATA%\\browsercli\\session.json`` on Windows.
|
|
108
|
+
timeout: Socket timeout in seconds (must be positive).
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
A connected :class:`BrowserCLI` instance.
|
|
112
|
+
|
|
113
|
+
Raises:
|
|
114
|
+
SessionError: If the session file is missing, unreadable, or
|
|
115
|
+
contains invalid data.
|
|
116
|
+
"""
|
|
117
|
+
if session_path is None:
|
|
118
|
+
if sys.platform == "win32":
|
|
119
|
+
app_data = os.environ.get("LOCALAPPDATA", str(Path.home()))
|
|
120
|
+
session_path = str(
|
|
121
|
+
Path(app_data) / "browsercli" / "session.json"
|
|
122
|
+
)
|
|
123
|
+
else:
|
|
124
|
+
session_path = str(
|
|
125
|
+
Path.home() / ".browsercli" / "session.json"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
with open(session_path) as f:
|
|
130
|
+
sess = json.load(f)
|
|
131
|
+
except FileNotFoundError:
|
|
132
|
+
raise SessionError(
|
|
133
|
+
f"Session file not found: {session_path} — "
|
|
134
|
+
"is the daemon running? (browsercli start)"
|
|
135
|
+
)
|
|
136
|
+
except (json.JSONDecodeError, OSError) as exc:
|
|
137
|
+
raise SessionError(
|
|
138
|
+
f"Cannot read session file {session_path}: {exc}"
|
|
139
|
+
) from exc
|
|
140
|
+
|
|
141
|
+
if not isinstance(sess, dict):
|
|
142
|
+
raise SessionError(
|
|
143
|
+
f"Session file {session_path} does not contain a JSON object"
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
socket_path_val = sess.get("socket_path", "")
|
|
147
|
+
rpc_port_val = sess.get("rpc_port", 0)
|
|
148
|
+
token = sess.get("token", "")
|
|
149
|
+
if not token:
|
|
150
|
+
raise SessionError(
|
|
151
|
+
"session.json is missing token; is the daemon running?"
|
|
152
|
+
)
|
|
153
|
+
if not socket_path_val and not rpc_port_val:
|
|
154
|
+
raise SessionError(
|
|
155
|
+
"session.json is missing socket_path and rpc_port; "
|
|
156
|
+
"is the daemon running?"
|
|
157
|
+
)
|
|
158
|
+
return cls(
|
|
159
|
+
socket_path_val,
|
|
160
|
+
token,
|
|
161
|
+
timeout=timeout,
|
|
162
|
+
rpc_port=rpc_port_val,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# ------------------------------------------------------------------
|
|
166
|
+
# Low-level RPC
|
|
167
|
+
# ------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
def _request(
|
|
170
|
+
self,
|
|
171
|
+
method: str,
|
|
172
|
+
path: str,
|
|
173
|
+
body: Optional[Dict[str, Any]] = None,
|
|
174
|
+
) -> Any:
|
|
175
|
+
"""Send an RPC request and return the parsed JSON response.
|
|
176
|
+
|
|
177
|
+
Raises:
|
|
178
|
+
ConnectionError: Socket connection failed.
|
|
179
|
+
AuthenticationError: HTTP 401 (token rejected).
|
|
180
|
+
BadRequestError: HTTP 400 (malformed request).
|
|
181
|
+
NotFoundError: HTTP 404 (unknown endpoint or element).
|
|
182
|
+
ServerError: HTTP 5xx (daemon internal error).
|
|
183
|
+
RPCError: Any other non-2xx status.
|
|
184
|
+
"""
|
|
185
|
+
logger.debug("RPC %s %s body=%s", method, path, body)
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
if self._socket_path:
|
|
189
|
+
conn = _UnixHTTPConnection(
|
|
190
|
+
self._socket_path, timeout=self._timeout
|
|
191
|
+
)
|
|
192
|
+
else:
|
|
193
|
+
conn = http.client.HTTPConnection(
|
|
194
|
+
"127.0.0.1", port=self._rpc_port, timeout=self._timeout
|
|
195
|
+
)
|
|
196
|
+
except OSError as exc:
|
|
197
|
+
addr_desc = self._socket_path or f"127.0.0.1:{self._rpc_port}"
|
|
198
|
+
raise ConnectionError(
|
|
199
|
+
f"Cannot create connection to {addr_desc}: {exc}"
|
|
200
|
+
) from exc
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
headers = {
|
|
204
|
+
"Authorization": f"Bearer {self._token}",
|
|
205
|
+
"Content-Type": "application/json",
|
|
206
|
+
}
|
|
207
|
+
payload = json.dumps(body).encode() if body else b""
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
conn.request(method, path, body=payload, headers=headers)
|
|
211
|
+
resp = conn.getresponse()
|
|
212
|
+
except (OSError, socket.timeout) as exc:
|
|
213
|
+
addr_desc = self._socket_path or f"127.0.0.1:{self._rpc_port}"
|
|
214
|
+
raise ConnectionError(
|
|
215
|
+
f"Failed to communicate with daemon at "
|
|
216
|
+
f"{addr_desc}: {exc}"
|
|
217
|
+
) from exc
|
|
218
|
+
|
|
219
|
+
raw_data = resp.read().decode("utf-8", errors="replace")
|
|
220
|
+
status = resp.status
|
|
221
|
+
|
|
222
|
+
logger.debug("RPC response status=%d body=%s", status, raw_data[:200])
|
|
223
|
+
|
|
224
|
+
if status == 401:
|
|
225
|
+
raise AuthenticationError(
|
|
226
|
+
"Daemon rejected the bearer token. "
|
|
227
|
+
"The daemon may have restarted — try BrowserCLI.connect() again."
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
if status >= 400:
|
|
231
|
+
# Try to extract the structured error message.
|
|
232
|
+
error_msg = raw_data.strip()
|
|
233
|
+
try:
|
|
234
|
+
error_json = json.loads(raw_data)
|
|
235
|
+
if isinstance(error_json, dict) and "error" in error_json:
|
|
236
|
+
error_msg = error_json["error"]
|
|
237
|
+
except (json.JSONDecodeError, ValueError):
|
|
238
|
+
pass # Use raw_data as-is
|
|
239
|
+
|
|
240
|
+
if status == 400:
|
|
241
|
+
raise BadRequestError(error_msg)
|
|
242
|
+
elif status == 404:
|
|
243
|
+
raise NotFoundError(error_msg)
|
|
244
|
+
elif status >= 500:
|
|
245
|
+
raise ServerError(status, error_msg)
|
|
246
|
+
else:
|
|
247
|
+
raise RPCError(status, error_msg)
|
|
248
|
+
|
|
249
|
+
# Success: parse JSON.
|
|
250
|
+
if not raw_data.strip():
|
|
251
|
+
return {}
|
|
252
|
+
try:
|
|
253
|
+
return json.loads(raw_data)
|
|
254
|
+
except json.JSONDecodeError as exc:
|
|
255
|
+
raise RPCError(
|
|
256
|
+
status,
|
|
257
|
+
f"Daemon returned invalid JSON (HTTP {status}): {exc}",
|
|
258
|
+
) from exc
|
|
259
|
+
finally:
|
|
260
|
+
conn.close()
|
|
261
|
+
|
|
262
|
+
# ------------------------------------------------------------------
|
|
263
|
+
# High-level API
|
|
264
|
+
# ------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
def status(self) -> Dict[str, Any]:
|
|
267
|
+
"""Return daemon and browser status.
|
|
268
|
+
|
|
269
|
+
Response keys include ``running``, ``browser_alive``, ``pid``,
|
|
270
|
+
``dir``, ``http_addr``, ``http_port``, ``current_url``, ``title``,
|
|
271
|
+
``headless``, ``browser_pid``, ``devtools_port``, ``browser_bin``.
|
|
272
|
+
"""
|
|
273
|
+
return self._request("GET", "/status")
|
|
274
|
+
|
|
275
|
+
def version(self) -> Dict[str, Any]:
|
|
276
|
+
"""Return RPC and schema version info.
|
|
277
|
+
|
|
278
|
+
Response keys: ``rpc_version``, ``schema_version``.
|
|
279
|
+
"""
|
|
280
|
+
return self._request("GET", "/version")
|
|
281
|
+
|
|
282
|
+
def goto(self, url: str) -> Dict[str, Any]:
|
|
283
|
+
"""Navigate the browser to *url* (path or full URL).
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
url: Absolute URL (``http://...``) or path (``/page``).
|
|
287
|
+
Paths are resolved relative to the daemon's serve directory.
|
|
288
|
+
|
|
289
|
+
Returns:
|
|
290
|
+
Dict with ``url`` (final URL after navigation) and ``title``.
|
|
291
|
+
"""
|
|
292
|
+
if not isinstance(url, str):
|
|
293
|
+
raise TypeError(f"url must be a string, got {type(url).__name__}")
|
|
294
|
+
return self._request("POST", "/goto", {"url": url})
|
|
295
|
+
|
|
296
|
+
def eval(self, expression: str) -> Any:
|
|
297
|
+
"""Evaluate a JavaScript expression and return the result value.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
expression: JavaScript code to evaluate in the page context.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
The evaluated result (any JSON-serializable type, or None).
|
|
304
|
+
"""
|
|
305
|
+
if not isinstance(expression, str) or not expression.strip():
|
|
306
|
+
raise ValueError("expression must be a non-empty string")
|
|
307
|
+
resp = self._request("POST", "/eval", {"expression": expression})
|
|
308
|
+
return resp.get("value")
|
|
309
|
+
|
|
310
|
+
def reload(self) -> bool:
|
|
311
|
+
"""Reload the current page.
|
|
312
|
+
|
|
313
|
+
Returns:
|
|
314
|
+
True on success.
|
|
315
|
+
"""
|
|
316
|
+
resp = self._request("POST", "/reload")
|
|
317
|
+
return resp.get("ok", False)
|
|
318
|
+
|
|
319
|
+
def dom_query(
|
|
320
|
+
self,
|
|
321
|
+
selector: str,
|
|
322
|
+
mode: str = "outer_html",
|
|
323
|
+
) -> str:
|
|
324
|
+
"""Query a single DOM element and return its content.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
selector: CSS selector string.
|
|
328
|
+
mode: ``"outer_html"`` (default) or ``"text"``.
|
|
329
|
+
|
|
330
|
+
Returns:
|
|
331
|
+
The element content as a string.
|
|
332
|
+
|
|
333
|
+
Raises:
|
|
334
|
+
ValueError: If *mode* is not a recognized value.
|
|
335
|
+
"""
|
|
336
|
+
if not isinstance(selector, str) or not selector.strip():
|
|
337
|
+
raise ValueError("selector must be a non-empty string")
|
|
338
|
+
if mode not in DOM_MODES:
|
|
339
|
+
raise ValueError(
|
|
340
|
+
f"mode must be one of {sorted(DOM_MODES)}, got {mode!r}"
|
|
341
|
+
)
|
|
342
|
+
resp = self._request(
|
|
343
|
+
"POST", "/dom", {"selector": selector, "mode": mode}
|
|
344
|
+
)
|
|
345
|
+
return resp.get("value", "")
|
|
346
|
+
|
|
347
|
+
def dom_all(
|
|
348
|
+
self,
|
|
349
|
+
selector: str,
|
|
350
|
+
mode: str = "outer_html",
|
|
351
|
+
) -> List[str]:
|
|
352
|
+
"""Query all matching DOM elements.
|
|
353
|
+
|
|
354
|
+
Args:
|
|
355
|
+
selector: CSS selector string.
|
|
356
|
+
mode: ``"outer_html"`` (default) or ``"text"``.
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
List of content strings for each matching element.
|
|
360
|
+
|
|
361
|
+
Raises:
|
|
362
|
+
ValueError: If *mode* is not a recognized value.
|
|
363
|
+
"""
|
|
364
|
+
if not isinstance(selector, str) or not selector.strip():
|
|
365
|
+
raise ValueError("selector must be a non-empty string")
|
|
366
|
+
if mode not in DOM_MODES:
|
|
367
|
+
raise ValueError(
|
|
368
|
+
f"mode must be one of {sorted(DOM_MODES)}, got {mode!r}"
|
|
369
|
+
)
|
|
370
|
+
resp = self._request(
|
|
371
|
+
"POST", "/dom/all", {"selector": selector, "mode": mode}
|
|
372
|
+
)
|
|
373
|
+
return resp.get("values", [])
|
|
374
|
+
|
|
375
|
+
def dom_attr(self, selector: str, name: str) -> Optional[str]:
|
|
376
|
+
"""Get an attribute value from an element.
|
|
377
|
+
|
|
378
|
+
Args:
|
|
379
|
+
selector: CSS selector for the target element.
|
|
380
|
+
name: Attribute name (e.g. ``"href"``, ``"class"``).
|
|
381
|
+
|
|
382
|
+
Returns:
|
|
383
|
+
Attribute value, or ``None`` if the attribute doesn't exist.
|
|
384
|
+
"""
|
|
385
|
+
if not isinstance(selector, str) or not selector.strip():
|
|
386
|
+
raise ValueError("selector must be a non-empty string")
|
|
387
|
+
if not isinstance(name, str) or not name.strip():
|
|
388
|
+
raise ValueError("name must be a non-empty string")
|
|
389
|
+
resp = self._request(
|
|
390
|
+
"POST", "/dom/attr", {"selector": selector, "name": name}
|
|
391
|
+
)
|
|
392
|
+
return resp.get("value")
|
|
393
|
+
|
|
394
|
+
def dom_click(self, selector: str) -> bool:
|
|
395
|
+
"""Click an element.
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
selector: CSS selector for the element to click.
|
|
399
|
+
|
|
400
|
+
Returns:
|
|
401
|
+
True on success.
|
|
402
|
+
"""
|
|
403
|
+
if not isinstance(selector, str) or not selector.strip():
|
|
404
|
+
raise ValueError("selector must be a non-empty string")
|
|
405
|
+
resp = self._request(
|
|
406
|
+
"POST", "/dom/click", {"selector": selector}
|
|
407
|
+
)
|
|
408
|
+
return resp.get("ok", False)
|
|
409
|
+
|
|
410
|
+
def dom_type(
|
|
411
|
+
self,
|
|
412
|
+
selector: str,
|
|
413
|
+
text: str,
|
|
414
|
+
clear: bool = False,
|
|
415
|
+
) -> bool:
|
|
416
|
+
"""Type text into an input element.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
selector: CSS selector for the input element.
|
|
420
|
+
text: Text to type.
|
|
421
|
+
clear: If True, clear the field before typing.
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
True on success.
|
|
425
|
+
"""
|
|
426
|
+
if not isinstance(selector, str) or not selector.strip():
|
|
427
|
+
raise ValueError("selector must be a non-empty string")
|
|
428
|
+
if not isinstance(text, str):
|
|
429
|
+
raise TypeError(f"text must be a string, got {type(text).__name__}")
|
|
430
|
+
resp = self._request(
|
|
431
|
+
"POST",
|
|
432
|
+
"/dom/type",
|
|
433
|
+
{"selector": selector, "text": text, "clear": clear},
|
|
434
|
+
)
|
|
435
|
+
return resp.get("ok", False)
|
|
436
|
+
|
|
437
|
+
def dom_wait(
|
|
438
|
+
self,
|
|
439
|
+
selector: str,
|
|
440
|
+
state: str = "visible",
|
|
441
|
+
timeout_ms: int = 10000,
|
|
442
|
+
) -> bool:
|
|
443
|
+
"""Wait for an element to reach a given state.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
selector: CSS selector for the target element.
|
|
447
|
+
state: ``"visible"`` (default), ``"hidden"``, ``"attached"``,
|
|
448
|
+
or ``"detached"``.
|
|
449
|
+
timeout_ms: Maximum wait time in milliseconds (default 10000).
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
True if the element reached the desired state.
|
|
453
|
+
|
|
454
|
+
Raises:
|
|
455
|
+
ValueError: If *state* is not a recognized value.
|
|
456
|
+
"""
|
|
457
|
+
if not isinstance(selector, str) or not selector.strip():
|
|
458
|
+
raise ValueError("selector must be a non-empty string")
|
|
459
|
+
if state not in WAIT_STATES:
|
|
460
|
+
raise ValueError(
|
|
461
|
+
f"state must be one of {sorted(WAIT_STATES)}, got {state!r}"
|
|
462
|
+
)
|
|
463
|
+
if not isinstance(timeout_ms, int) or timeout_ms <= 0:
|
|
464
|
+
raise ValueError("timeout_ms must be a positive integer")
|
|
465
|
+
resp = self._request(
|
|
466
|
+
"POST",
|
|
467
|
+
"/dom/wait",
|
|
468
|
+
{
|
|
469
|
+
"selector": selector,
|
|
470
|
+
"state": state,
|
|
471
|
+
"timeout_ms": timeout_ms,
|
|
472
|
+
},
|
|
473
|
+
)
|
|
474
|
+
return resp.get("ok", False)
|
|
475
|
+
|
|
476
|
+
def screenshot(
|
|
477
|
+
self,
|
|
478
|
+
selector: str = "",
|
|
479
|
+
out: Optional[Union[str, Path]] = None,
|
|
480
|
+
) -> bytes:
|
|
481
|
+
"""Take a screenshot. Returns raw PNG bytes.
|
|
482
|
+
|
|
483
|
+
Args:
|
|
484
|
+
selector: Optional CSS selector to screenshot a specific element.
|
|
485
|
+
Empty string (default) captures the full page.
|
|
486
|
+
out: If given, the PNG is also written to this file path.
|
|
487
|
+
|
|
488
|
+
Returns:
|
|
489
|
+
Raw PNG image bytes.
|
|
490
|
+
"""
|
|
491
|
+
if not isinstance(selector, str):
|
|
492
|
+
raise TypeError(f"selector must be a string, got {type(selector).__name__}")
|
|
493
|
+
resp = self._request(
|
|
494
|
+
"POST",
|
|
495
|
+
"/screenshot",
|
|
496
|
+
{"selector": selector, "format": "png"},
|
|
497
|
+
)
|
|
498
|
+
b64_data = resp.get("base64", "")
|
|
499
|
+
if not b64_data:
|
|
500
|
+
return b""
|
|
501
|
+
raw = base64.b64decode(b64_data)
|
|
502
|
+
if out:
|
|
503
|
+
Path(out).write_bytes(raw)
|
|
504
|
+
return raw
|
|
505
|
+
|
|
506
|
+
def console(
|
|
507
|
+
self,
|
|
508
|
+
level: str = "",
|
|
509
|
+
limit: int = 0,
|
|
510
|
+
clear: bool = False,
|
|
511
|
+
) -> List[Dict[str, Any]]:
|
|
512
|
+
"""Fetch console entries.
|
|
513
|
+
|
|
514
|
+
Args:
|
|
515
|
+
level: Filter by level: ``"log"``, ``"warn"``, ``"error"``,
|
|
516
|
+
``"info"``, or ``""`` (all).
|
|
517
|
+
limit: Maximum number of entries to return (0 = unlimited).
|
|
518
|
+
clear: If True, drain (delete) entries after reading.
|
|
519
|
+
|
|
520
|
+
Returns:
|
|
521
|
+
List of dicts with keys ``level``, ``text``, ``timestamp``.
|
|
522
|
+
|
|
523
|
+
Raises:
|
|
524
|
+
ValueError: If *level* is not a recognized value.
|
|
525
|
+
"""
|
|
526
|
+
if level not in CONSOLE_LEVELS:
|
|
527
|
+
raise ValueError(
|
|
528
|
+
f"level must be one of {sorted(CONSOLE_LEVELS)}, got {level!r}"
|
|
529
|
+
)
|
|
530
|
+
if not isinstance(limit, int) or limit < 0:
|
|
531
|
+
raise ValueError("limit must be a non-negative integer")
|
|
532
|
+
resp = self._request(
|
|
533
|
+
"POST",
|
|
534
|
+
"/console",
|
|
535
|
+
{"level": level, "limit": limit, "clear": clear},
|
|
536
|
+
)
|
|
537
|
+
return resp.get("entries", [])
|
|
538
|
+
|
|
539
|
+
def network(
|
|
540
|
+
self,
|
|
541
|
+
limit: int = 0,
|
|
542
|
+
clear: bool = False,
|
|
543
|
+
) -> List[Dict[str, Any]]:
|
|
544
|
+
"""Fetch network log entries.
|
|
545
|
+
|
|
546
|
+
Args:
|
|
547
|
+
limit: Maximum number of entries to return (0 = unlimited).
|
|
548
|
+
clear: If True, drain (delete) entries after reading.
|
|
549
|
+
|
|
550
|
+
Returns:
|
|
551
|
+
List of dicts with keys ``method``, ``url``, ``status``,
|
|
552
|
+
``resource_type``, ``mime_type``, ``size``, ``duration_ms``,
|
|
553
|
+
``timestamp``.
|
|
554
|
+
"""
|
|
555
|
+
if not isinstance(limit, int) or limit < 0:
|
|
556
|
+
raise ValueError("limit must be a non-negative integer")
|
|
557
|
+
resp = self._request(
|
|
558
|
+
"POST",
|
|
559
|
+
"/network",
|
|
560
|
+
{"limit": limit, "clear": clear},
|
|
561
|
+
)
|
|
562
|
+
return resp.get("entries", [])
|
|
563
|
+
|
|
564
|
+
def perf(self) -> Dict[str, float]:
|
|
565
|
+
"""Return page performance metrics.
|
|
566
|
+
|
|
567
|
+
Returns:
|
|
568
|
+
Dict with ``dom_content_loaded_ms`` and ``load_event_ms``.
|
|
569
|
+
"""
|
|
570
|
+
return self._request("GET", "/perf")
|
|
571
|
+
|
|
572
|
+
def stop(self) -> bool:
|
|
573
|
+
"""Stop the daemon.
|
|
574
|
+
|
|
575
|
+
Returns:
|
|
576
|
+
True on success.
|
|
577
|
+
"""
|
|
578
|
+
resp = self._request("POST", "/stop")
|
|
579
|
+
return resp.get("ok", False)
|
|
580
|
+
|
|
581
|
+
def plugin_list(self) -> List[Dict[str, Any]]:
|
|
582
|
+
"""List all installed plugins.
|
|
583
|
+
|
|
584
|
+
Returns:
|
|
585
|
+
List of dicts with keys ``name``, ``version``, ``description``,
|
|
586
|
+
``templates``, ``hooks``, ``rpc_endpoints``.
|
|
587
|
+
"""
|
|
588
|
+
resp = self._request("GET", "/plugins")
|
|
589
|
+
return resp.get("plugins", [])
|
|
590
|
+
|
|
591
|
+
def plugin_rpc(
|
|
592
|
+
self,
|
|
593
|
+
rpc_path: str,
|
|
594
|
+
body: Optional[Dict[str, Any]] = None,
|
|
595
|
+
) -> Any:
|
|
596
|
+
"""Call a custom plugin RPC endpoint.
|
|
597
|
+
|
|
598
|
+
Args:
|
|
599
|
+
rpc_path: The endpoint path (must start with ``/x/``).
|
|
600
|
+
body: Optional JSON request body for the handler.
|
|
601
|
+
|
|
602
|
+
Returns:
|
|
603
|
+
The parsed JSON response from the plugin handler.
|
|
604
|
+
|
|
605
|
+
Raises:
|
|
606
|
+
ValueError: If *rpc_path* doesn't start with ``/x/``.
|
|
607
|
+
"""
|
|
608
|
+
if not isinstance(rpc_path, str) or not rpc_path.startswith("/x/"):
|
|
609
|
+
raise ValueError(
|
|
610
|
+
f"rpc_path must be a string starting with '/x/', got {rpc_path!r}"
|
|
611
|
+
)
|
|
612
|
+
return self._request("POST", rpc_path, body)
|
|
613
|
+
|
|
614
|
+
# ------------------------------------------------------------------
|
|
615
|
+
# Context manager
|
|
616
|
+
# ------------------------------------------------------------------
|
|
617
|
+
|
|
618
|
+
def __enter__(self) -> BrowserCLI:
|
|
619
|
+
return self
|
|
620
|
+
|
|
621
|
+
def __exit__(self, *exc: Any) -> None:
|
|
622
|
+
pass # does NOT auto-stop; caller decides
|
|
623
|
+
|
|
624
|
+
# ------------------------------------------------------------------
|
|
625
|
+
# repr
|
|
626
|
+
# ------------------------------------------------------------------
|
|
627
|
+
|
|
628
|
+
def __repr__(self) -> str:
|
|
629
|
+
addr = self._socket_path or f"127.0.0.1:{self._rpc_port}"
|
|
630
|
+
return f"BrowserCLI(addr={addr!r}, timeout={self._timeout})"
|
browsercli/exceptions.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Exception hierarchy for the browsercli Python client.
|
|
2
|
+
|
|
3
|
+
All public exceptions inherit from :class:`BrowserCLIError` so callers
|
|
4
|
+
can catch the whole family with a single ``except BrowserCLIError``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class BrowserCLIError(Exception):
|
|
11
|
+
"""Base exception for all browsercli client errors."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ConnectionError(BrowserCLIError):
|
|
15
|
+
"""The client could not connect to the daemon Unix socket.
|
|
16
|
+
|
|
17
|
+
Common causes:
|
|
18
|
+
- The daemon is not running (``browsercli start`` not called).
|
|
19
|
+
- The socket file was deleted or has wrong permissions.
|
|
20
|
+
- The session file points to a stale socket.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AuthenticationError(BrowserCLIError):
|
|
25
|
+
"""The daemon rejected the bearer token (HTTP 401).
|
|
26
|
+
|
|
27
|
+
Common causes:
|
|
28
|
+
- The daemon was restarted and generated a new token.
|
|
29
|
+
- The session file is stale. Re-read it with ``BrowserCLI.connect()``.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class RPCError(BrowserCLIError):
|
|
34
|
+
"""The daemon returned an HTTP error with a JSON ``{"error": "..."}`` body.
|
|
35
|
+
|
|
36
|
+
Attributes:
|
|
37
|
+
status_code: HTTP status code (e.g. 400, 404, 500).
|
|
38
|
+
error_message: Human-readable error message from the daemon.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, status_code: int, error_message: str) -> None:
|
|
42
|
+
self.status_code = status_code
|
|
43
|
+
self.error_message = error_message
|
|
44
|
+
super().__init__(f"RPC error {status_code}: {error_message}")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class BadRequestError(RPCError):
|
|
48
|
+
"""The daemon returned HTTP 400 — the request body was malformed."""
|
|
49
|
+
|
|
50
|
+
def __init__(self, error_message: str) -> None:
|
|
51
|
+
super().__init__(400, error_message)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class NotFoundError(RPCError):
|
|
55
|
+
"""The daemon returned HTTP 404 — unknown endpoint or missing element."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, error_message: str) -> None:
|
|
58
|
+
super().__init__(404, error_message)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class ServerError(RPCError):
|
|
62
|
+
"""The daemon returned HTTP 5xx — an internal error occurred."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, status_code: int, error_message: str) -> None:
|
|
65
|
+
super().__init__(status_code, error_message)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SessionError(BrowserCLIError):
|
|
69
|
+
"""The session file is missing, unreadable, or has invalid content."""
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: browsercli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Python client for the browsercli browser workspace daemon
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/justinhuangcode/browsercli
|
|
7
|
+
Project-URL: Repository, https://github.com/justinhuangcode/browsercli
|
|
8
|
+
Project-URL: Documentation, https://github.com/justinhuangcode/browsercli#readme
|
|
9
|
+
Project-URL: Bug Reports, https://github.com/justinhuangcode/browsercli/issues
|
|
10
|
+
Keywords: browser,automation,rpc,cdp,devtools
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Testing
|
|
20
|
+
Classifier: Typing :: Typed
|
|
21
|
+
Requires-Python: >=3.9
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# browsercli Python Client
|
|
25
|
+
|
|
26
|
+
Zero-dependency Python client for the [browsercli](https://github.com/justinhuangcode/browsercli) browser workspace daemon.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install -e clients/python # from the repo root
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Platform Support
|
|
35
|
+
|
|
36
|
+
The client auto-detects the RPC transport from the session file:
|
|
37
|
+
|
|
38
|
+
- **macOS / Linux** — connects via Unix socket (`socket_path` in session.json)
|
|
39
|
+
- **Windows** — connects via TCP localhost (`rpc_port` in session.json)
|
|
40
|
+
|
|
41
|
+
No code changes are needed — `BrowserCLI.connect()` handles both transports.
|
|
42
|
+
|
|
43
|
+
## Quick Start
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from browsercli import BrowserCLI
|
|
47
|
+
|
|
48
|
+
# Connect to a running daemon
|
|
49
|
+
# macOS/Linux: reads ~/.browsercli/session.json
|
|
50
|
+
# Windows: reads %LOCALAPPDATA%\browsercli\session.json
|
|
51
|
+
ac = BrowserCLI.connect()
|
|
52
|
+
|
|
53
|
+
# Navigate and inspect
|
|
54
|
+
ac.goto("/")
|
|
55
|
+
title = ac.dom_query("h1", mode="text")
|
|
56
|
+
print(f"Title: {title}")
|
|
57
|
+
|
|
58
|
+
# Evaluate JavaScript
|
|
59
|
+
result = ac.eval("1 + 1")
|
|
60
|
+
print(f"Result: {result}")
|
|
61
|
+
|
|
62
|
+
# Screenshot
|
|
63
|
+
ac.screenshot(out="page.png")
|
|
64
|
+
|
|
65
|
+
# Stop the daemon
|
|
66
|
+
ac.stop()
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Error Handling
|
|
70
|
+
|
|
71
|
+
All exceptions inherit from `BrowserCLIError`, so you can catch the whole family or handle specific cases:
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from browsercli import (
|
|
75
|
+
BrowserCLI,
|
|
76
|
+
BrowserCLIError,
|
|
77
|
+
ConnectionError,
|
|
78
|
+
AuthenticationError,
|
|
79
|
+
SessionError,
|
|
80
|
+
NotFoundError,
|
|
81
|
+
ServerError,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
try:
|
|
85
|
+
ac = BrowserCLI.connect()
|
|
86
|
+
ac.dom_query("#missing-element", mode="text")
|
|
87
|
+
except SessionError:
|
|
88
|
+
print("Daemon not running — start it with: browsercli start")
|
|
89
|
+
except ConnectionError:
|
|
90
|
+
print("Cannot reach daemon — is the socket file valid?")
|
|
91
|
+
except AuthenticationError:
|
|
92
|
+
print("Token rejected — daemon may have restarted, reconnect")
|
|
93
|
+
except NotFoundError as e:
|
|
94
|
+
print(f"Element or endpoint not found: {e}")
|
|
95
|
+
except ServerError as e:
|
|
96
|
+
print(f"Daemon internal error (HTTP {e.status_code}): {e.error_message}")
|
|
97
|
+
except BrowserCLIError as e:
|
|
98
|
+
print(f"Unexpected client error: {e}")
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Exception Hierarchy
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
BrowserCLIError # Base — catch-all
|
|
105
|
+
├── SessionError # session.json missing/invalid
|
|
106
|
+
├── ConnectionError # RPC endpoint unreachable (socket or TCP)
|
|
107
|
+
├── AuthenticationError # HTTP 401 (bad token)
|
|
108
|
+
└── RPCError # Any HTTP 4xx/5xx with status_code + error_message
|
|
109
|
+
├── BadRequestError # HTTP 400
|
|
110
|
+
├── NotFoundError # HTTP 404
|
|
111
|
+
└── ServerError # HTTP 5xx
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Constructor
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
BrowserCLI(socket_path="", token="", timeout=30.0, *, rpc_port=0)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
| Parameter | Type | Description |
|
|
121
|
+
| --- | --- | --- |
|
|
122
|
+
| `socket_path` | `str` | Unix socket path (macOS/Linux). Mutually exclusive with `rpc_port`. |
|
|
123
|
+
| `token` | `str` | Bearer token for daemon authentication |
|
|
124
|
+
| `timeout` | `float` | Request timeout in seconds (default `30.0`) |
|
|
125
|
+
| `rpc_port` | `int` | TCP port on localhost (Windows). Keyword-only. |
|
|
126
|
+
|
|
127
|
+
You must provide either `socket_path` or `rpc_port`. In most cases, use the `connect()` factory instead of calling the constructor directly.
|
|
128
|
+
|
|
129
|
+
## API Reference
|
|
130
|
+
|
|
131
|
+
| Method | Description |
|
|
132
|
+
| --- | --- |
|
|
133
|
+
| `BrowserCLI.connect(session_path=None, timeout=30.0)` | Create client from session file (auto-detects Unix socket or TCP) |
|
|
134
|
+
| `status()` | Daemon and browser status |
|
|
135
|
+
| `version()` | RPC and schema version info |
|
|
136
|
+
| `goto(url)` | Navigate to a path or URL |
|
|
137
|
+
| `eval(expression)` | Evaluate JavaScript |
|
|
138
|
+
| `reload()` | Reload the page |
|
|
139
|
+
| `dom_query(selector, mode)` | Query a single DOM element |
|
|
140
|
+
| `dom_all(selector, mode)` | Query all matching elements |
|
|
141
|
+
| `dom_attr(selector, name)` | Get an element attribute |
|
|
142
|
+
| `dom_click(selector)` | Click an element |
|
|
143
|
+
| `dom_type(selector, text, clear)` | Type text into an input |
|
|
144
|
+
| `dom_wait(selector, state, timeout_ms)` | Wait for element state |
|
|
145
|
+
| `screenshot(selector, out)` | Capture screenshot (PNG bytes) |
|
|
146
|
+
| `console(level, limit, clear)` | Fetch console entries |
|
|
147
|
+
| `network(limit, clear)` | Fetch network log entries |
|
|
148
|
+
| `perf()` | Page performance metrics |
|
|
149
|
+
| `stop()` | Stop the daemon |
|
|
150
|
+
| `plugin_list()` | List installed plugins |
|
|
151
|
+
| `plugin_rpc(rpc_path, body=None)` | Call a custom plugin RPC endpoint (`/x/...`) |
|
|
152
|
+
|
|
153
|
+
### Valid Parameter Values
|
|
154
|
+
|
|
155
|
+
| Parameter | Valid Values | Default |
|
|
156
|
+
| --- | --- | --- |
|
|
157
|
+
| `dom_query` / `dom_all` `mode` | `"outer_html"`, `"text"` | `"outer_html"` |
|
|
158
|
+
| `dom_wait` `state` | `"visible"`, `"hidden"`, `"attached"`, `"detached"` | `"visible"` |
|
|
159
|
+
| `console` `level` | `""` (all), `"log"`, `"warn"`, `"error"`, `"info"` | `""` |
|
|
160
|
+
|
|
161
|
+
Invalid values raise `ValueError` before any RPC call is made.
|
|
162
|
+
|
|
163
|
+
## Logging
|
|
164
|
+
|
|
165
|
+
The client uses Python's `logging` module under the logger name `browsercli`:
|
|
166
|
+
|
|
167
|
+
```python
|
|
168
|
+
import logging
|
|
169
|
+
logging.basicConfig(level=logging.DEBUG)
|
|
170
|
+
# Now all RPC requests and responses are logged.
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Running Tests
|
|
174
|
+
|
|
175
|
+
```bash
|
|
176
|
+
cd clients/python
|
|
177
|
+
python -m unittest tests.test_client -v
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Tests include:
|
|
181
|
+
- **Session parsing**: valid/invalid/missing session files (including `rpc_port` for Windows)
|
|
182
|
+
- **Parameter validation**: client-side checks for all method arguments
|
|
183
|
+
- **Contract tests**: mock server (Unix socket on macOS/Linux, TCP on Windows) emulating the Rust daemon
|
|
184
|
+
- **Error handling**: auth failures, 400/404/500 errors, connection errors
|
|
185
|
+
- **Exception hierarchy**: inheritance and attribute verification
|
|
186
|
+
|
|
187
|
+
## Requirements
|
|
188
|
+
|
|
189
|
+
- Python 3.9+
|
|
190
|
+
- A running `browsercli` daemon (`browsercli start`)
|
|
191
|
+
- No external dependencies (stdlib only)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
browsercli/__init__.py,sha256=C9SmYcUwetW7IXnKUz5B9fjNEzm6Ov_-sfTpuvAkD18,551
|
|
2
|
+
browsercli/client.py,sha256=976D8nJ0VdKFqG12RlfSR0EiMsROKaPCOjlBqHPQ7VA,20943
|
|
3
|
+
browsercli/exceptions.py,sha256=tLWFrP1gAiSEO6VwCLdZg4bqUUg8FnfxO0cUIGFPUlE,2157
|
|
4
|
+
browsercli-1.0.0.dist-info/METADATA,sha256=TTtXiCe3evs8LlcWqJqatc8b48963WtrXroheAOIFe4,6472
|
|
5
|
+
browsercli-1.0.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
6
|
+
browsercli-1.0.0.dist-info/top_level.txt,sha256=S0RLonnqiDdQWflgQnN_p1V5hM9f275oVDChwW2rsKg,11
|
|
7
|
+
browsercli-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
browsercli
|