feat(sdk): improve Python SDK ergonomics
Testing / remote-protocol-compat (0.9.3) (push) Successful in 42s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 45s
Testing / test (push) Successful in 42s

- Position browser-cli as a CLI plus Python SDK in docs and package metadata.
- Add public target properties and a raw command escape hatch for unsupported commands.
- Add convenience helpers for opening, finding, closing, and accessing tabs.
- Add plural group aliases and a wait_for_selector DOM convenience alias.
- Extend bound Tab objects with screenshot, pin, refresh, load wait, and URL watch helpers.
- Preserve remote auth key configuration when binding remote Tab and Group objects.
- Bump project and extension versions to 0.9.9 and cover SDK additions with tests.
This commit is contained in:
2026-05-19 20:12:16 +02:00
parent eaa1469143
commit e1e4adbb25
7 changed files with 364 additions and 17 deletions
+109 -1
View File
@@ -1,5 +1,5 @@
"""
browser_cli — Python API for controlling your running browser.
browser_cli — Python SDK for controlling your running browser.
Usage:
from browser_cli import BrowserCLI
@@ -50,9 +50,32 @@ class BrowserCLI:
self._remote = remote
self._key = key if key else None
@property
def browser(self) -> str | None:
"""Target browser/profile alias, equivalent to ``--browser``."""
return self._browser
@property
def remote(self) -> str | None:
"""Remote endpoint used by this client, if any."""
return self._remote
@property
def key(self) -> str | None:
"""Ed25519 key spec used for remote auth, if explicitly configured."""
return self._key
def _cmd(self, command: str, args: dict | None = None):
return send_command(command, args, profile=self._browser, remote=self._remote, key=self._key)
def command(self, command: str, args: dict | None = None):
"""Send a raw browser-cli command and return its response.
This is the SDK escape hatch for commands that do not have a dedicated
convenience method yet.
"""
return self._cmd(command, args or {})
def _multi_browser_targets(self):
if self._browser is not None:
return []
@@ -109,6 +132,7 @@ class BrowserCLI:
tab._browser = self if browser_profile is None else BrowserCLI(
browser=browser_profile,
remote=browser_remote,
key=self._key,
)
return tab
@@ -131,6 +155,7 @@ class BrowserCLI:
group._browser = self if browser_profile is None else BrowserCLI(
browser=browser_profile,
remote=browser_remote,
key=self._key,
)
return group
@@ -139,6 +164,27 @@ class BrowserCLI:
def open(self, url: str, *, background: bool = False, window: str | None = None, group: str | None = None) -> None:
self._cmd("navigate.open", {"url": url, "background": background, "window": window, "group": group})
def open_tab(
self,
url: str,
*,
wait: bool = False,
timeout: float = 30.0,
background: bool = False,
window: str | None = None,
group: str | None = None,
) -> "Tab":
"""Open URL in a new tab and return a bound :class:`Tab` object.
Set ``wait=True`` to block until the page reaches ``readyState=complete``.
"""
if wait:
return self.open_wait(url, timeout=timeout, background=background, window=window, group=group)
data = self._cmd("navigate.open", {"url": url, "background": background, "window": window, "group": group})
if not isinstance(data, dict) or "id" not in data:
raise RuntimeError("navigate.open returned unexpected data")
return self._make_tab(data)
def reload(self, tab_id: int | None = None) -> None:
self._cmd("navigate.reload", {"tabId": tab_id})
@@ -216,6 +262,32 @@ class BrowserCLI:
# ── Tabs ──────────────────────────────────────────────────────────────
def tabs(self) -> list[Tab]:
"""Alias for :meth:`tabs_list`."""
return self.tabs_list()
def tab(self, tab_id: int) -> Tab:
"""Return a specific tab by ID."""
return self.tabs_status(tab_id)
def active_tab(self) -> Tab:
"""Return the active tab."""
return self.tabs_status()
def find_tabs(self, search: str) -> list[Tab]:
"""Alias for :meth:`tabs_query`."""
return self.tabs_query(search)
def find_tab(self, search: str) -> Tab | None:
"""Return the first tab matching *search*, or ``None``."""
matches = self.tabs_query(search)
return matches[0] if matches else None
def close_tab(self, tab: int | Tab) -> int:
"""Close a tab by ID or :class:`Tab` object. Returns count closed."""
tab_id = tab.id if isinstance(tab, Tab) else tab
return self.tabs_close(tab_id)
def tabs_list(self) -> list[Tab]:
"""Return all open tabs across all windows.
@@ -370,6 +442,14 @@ class BrowserCLI:
# ── Tab Groups ────────────────────────────────────────────────────────
def groups(self) -> list[Group]:
"""Alias for :meth:`group_list`."""
return self.group_list()
def groups_list(self) -> list[Group]:
"""Alias for :meth:`group_list`."""
return self.group_list()
def group_list(self) -> list[Group]:
"""Return all tab groups.
@@ -394,6 +474,10 @@ class BrowserCLI:
"""Return all tabs inside a group."""
return [self._make_tab(t) for t in (self._cmd("group.tabs", {"groupId": group_id}) or [])]
def groups_count(self) -> int | BrowserCounts:
"""Alias for :meth:`group_count`."""
return self.group_count()
def group_count(self) -> int | BrowserCounts:
"""Return the number of tab groups.
@@ -405,6 +489,10 @@ class BrowserCLI:
return BrowserCounts(total=sum(by_browser.values()), by_browser=by_browser)
return self._cmd("group.count", {})
def groups_query(self, search: str) -> list[Group]:
"""Alias for :meth:`group_query`."""
return self.group_query(search)
def group_query(self, search: str) -> list[Group]:
"""Search groups by name."""
return [self._make_group(g) for g in (self._cmd("group.query", {"search": search}) or [])]
@@ -413,6 +501,14 @@ class BrowserCLI:
"""Ungroup (and close) a tab group by ID."""
self._cmd("group.close", {"groupId": group_id})
def groups_create(self, name: str) -> Group:
"""Alias for :meth:`group_create`."""
return self.group_create(name)
def group_open(self, name: str) -> Group:
"""Alias for :meth:`group_create`."""
return self.group_create(name)
def group_create(self, name: str) -> Group:
"""Create a new tab group with *name*. Returns the created Group."""
data = self._cmd("group.open", {"name": name})
@@ -536,6 +632,18 @@ class BrowserCLI:
"tabId": tab_id,
})
def wait_for_selector(
self,
selector: str,
*,
timeout: float = 10.0,
visible: bool = False,
hidden: bool = False,
tab_id: int | None = None,
) -> dict:
"""Alias for :meth:`dom_wait_for`."""
return self.dom_wait_for(selector, timeout=timeout, visible=visible, hidden=hidden, tab_id=tab_id)
def dom_wait_for(
self,
selector: str,