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 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})"
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ browsercli