From 7347d981c48810795cf7d8ff6d8db5292424dce2 Mon Sep 17 00:00:00 2001 From: Daniel Dolezal Date: Thu, 9 Apr 2026 08:49:21 +0200 Subject: [PATCH] update python module with new commands and new classes --- browser_cli/__init__.py | 259 ++++++++++++++++++++++++++++------------ browser_cli/models.py | 131 ++++++++++++++++++++ 2 files changed, 311 insertions(+), 79 deletions(-) create mode 100644 browser_cli/models.py diff --git a/browser_cli/__init__.py b/browser_cli/__init__.py index 947ae4a..a091004 100644 --- a/browser_cli/__init__.py +++ b/browser_cli/__init__.py @@ -4,165 +4,266 @@ browser_cli — Python API for controlling your running browser. Usage: from browser_cli import BrowserCLI b = BrowserCLI() - tabs = b.tabs_list() - b.open("https://example.com") + + tabs = b.tabs_list() # list[Tab] + tabs[0].close() + tabs[0].move(forward=True) + + groups = b.group_list() # list[Group] + groups[0].tabs() + groups[0].add_tab("https://example.com") + + # When multiple browser instances are active, pass the alias: + b = BrowserCLI(browser="brave") """ from browser_cli.client import BrowserNotConnected, send_command +from browser_cli.models import Group, Tab -__all__ = ["BrowserCLI", "BrowserNotConnected"] +__all__ = ["BrowserCLI", "BrowserNotConnected", "Tab", "Group"] class BrowserCLI: + def __init__(self, browser: str | None = None): + """ + Args: + browser: Profile alias to target. Required when multiple browser + instances are active. Equivalent to ``--browser`` on the CLI. + """ + self._browser = browser + + def _cmd(self, command: str, args: dict | None = None): + return send_command(command, args, profile=self._browser) + + # ── Internal factories ──────────────────────────────────────────────── + + def _make_tab(self, data: dict) -> Tab: + tab = Tab( + id=data["id"], + window_id=data.get("windowId", 0), + active=data.get("active", False), + title=data.get("title") or "", + url=data.get("url") or "", + group_id=data.get("groupId") or None, + ) + tab._browser = self + return tab + + def _make_group(self, data: dict) -> Group: + group = Group( + id=data["id"], + title=data.get("title") or "", + color=data.get("color") or "", + collapsed=data.get("collapsed", False), + tab_count=data.get("tabCount", 0), + ) + group._browser = self + return group + # ── Navigation ──────────────────────────────────────────────────────── - def open(self, url: str, *, background: bool = False, window: str | None = None): - return send_command("navigate.open", {"url": url, "background": background, "window": window}) + 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 reload(self, tab_id: int | None = None): - return send_command("navigate.reload", {"tabId": tab_id}) + def reload(self, tab_id: int | None = None) -> None: + self._cmd("navigate.reload", {"tabId": tab_id}) - def hard_reload(self, tab_id: int | None = None): - return send_command("navigate.hard_reload", {"tabId": tab_id}) + def hard_reload(self, tab_id: int | None = None) -> None: + self._cmd("navigate.hard_reload", {"tabId": tab_id}) - def back(self, tab_id: int | None = None): - return send_command("navigate.back", {"tabId": tab_id}) + def back(self, tab_id: int | None = None) -> None: + self._cmd("navigate.back", {"tabId": tab_id}) - def forward(self, tab_id: int | None = None): - return send_command("navigate.forward", {"tabId": tab_id}) + def forward(self, tab_id: int | None = None) -> None: + self._cmd("navigate.forward", {"tabId": tab_id}) - def focus_url(self, pattern: str): - return send_command("navigate.focus", {"pattern": pattern}) + def focus_url(self, pattern: str) -> None: + self._cmd("navigate.focus", {"pattern": pattern}) + + # ── Search ──────────────────────────────────────────────────────────── + + def search( + self, engine: str, query: str, *, + background: bool = False, window: str | None = None, group: str | None = None, + ) -> None: + """Open a search query in the given engine (e.g. 'google', 'youtube', 'ddg').""" + from urllib.parse import quote_plus + from browser_cli.commands.search import ENGINES + template = ENGINES.get(engine) + if template is None: + raise ValueError(f"Unknown search engine '{engine}'. Available: {', '.join(ENGINES)}") + url = template.format(query=quote_plus(query)) + self._cmd("navigate.open", {"url": url, "background": background, "window": window, "group": group}) # ── Tabs ────────────────────────────────────────────────────────────── - def tabs_list(self) -> list[dict]: - return send_command("tabs.list", {}) + def tabs_list(self) -> list[Tab]: + """Return all open tabs across all windows.""" + return [self._make_tab(t) for t in (self._cmd("tabs.list", {}) or [])] - def tabs_close(self, tab_id: int | None = None, *, inactive: bool = False, duplicates: bool = False): - return send_command("tabs.close", {"tabId": tab_id, "inactive": inactive, "duplicates": duplicates}) + def tabs_close(self, tab_id: int | None = None, *, inactive: bool = False, duplicates: bool = False) -> int: + """Close tab(s). Returns the number of tabs closed.""" + result = self._cmd("tabs.close", {"tabId": tab_id, "inactive": inactive, "duplicates": duplicates}) + return result.get("closed", 1) if isinstance(result, dict) else 1 - def tabs_move(self, tab_id: int, *, group_id: int | None = None, window_id: int | None = None): - return send_command("tabs.move", {"tabId": tab_id, "groupId": group_id, "windowId": window_id}) + def tabs_move( + self, tab_id: int, *, + forward: bool = False, backward: bool = False, + group_id: int | None = None, window_id: int | None = None, index: int | None = None, + ) -> None: + self._cmd("tabs.move", { + "tabId": tab_id, "forward": forward, "backward": backward, + "groupId": group_id, "windowId": window_id, "index": index, + }) - def tabs_active(self, tab_id: int): - return send_command("tabs.active", {"tabId": tab_id}) + def tabs_active(self, tab_id: int) -> None: + """Switch browser focus to a tab by ID.""" + self._cmd("tabs.active", {"tabId": tab_id}) - def tabs_filter(self, pattern: str) -> list[dict]: - return send_command("tabs.filter", {"pattern": pattern}) + def tabs_filter(self, pattern: str) -> list[Tab]: + """Return tabs whose URL contains *pattern*.""" + return [self._make_tab(t) for t in (self._cmd("tabs.filter", {"pattern": pattern}) or [])] def tabs_count(self, pattern: str | None = None) -> int: - return send_command("tabs.count", {"pattern": pattern}) + """Count open tabs, optionally filtered by URL pattern.""" + return self._cmd("tabs.count", {"pattern": pattern}) - def tabs_query(self, search: str) -> list[dict]: - return send_command("tabs.query", {"search": search}) + def tabs_query(self, search: str) -> list[Tab]: + """Search tabs by URL or title.""" + return [self._make_tab(t) for t in (self._cmd("tabs.query", {"search": search}) or [])] def tabs_html(self, tab_id: int | None = None) -> str: - return send_command("tabs.html", {"tabId": tab_id}) + """Return the full HTML source of the active (or specified) tab.""" + return self._cmd("tabs.html", {"tabId": tab_id}) or "" - def tabs_dedupe(self): - return send_command("tabs.dedupe", {}) + def tabs_dedupe(self) -> int: + """Close duplicate tabs (keep the first occurrence of each URL). Returns count closed.""" + result = self._cmd("tabs.dedupe", {}) + return result.get("closed", 0) if isinstance(result, dict) else 0 - def tabs_sort(self, by: str = "domain"): - return send_command("tabs.sort", {"by": by}) + def tabs_sort(self, by: str = "domain") -> None: + """Sort tabs within each window. *by* is one of 'domain', 'title', 'time'.""" + self._cmd("tabs.sort", {"by": by}) - def tabs_merge_windows(self): - return send_command("tabs.merge_windows", {}) + def tabs_merge_windows(self) -> int: + """Move all tabs into the focused window. Returns count moved.""" + result = self._cmd("tabs.merge_windows", {}) + return result.get("moved", 0) if isinstance(result, dict) else 0 - def tabs_close_inactive(self): - return send_command("tabs.close", {"inactive": True}) + def tabs_close_inactive(self) -> int: + """Close all inactive tabs. Returns count closed.""" + result = self._cmd("tabs.close", {"inactive": True}) + return result.get("closed", 0) if isinstance(result, dict) else 0 - def tabs_close_duplicates(self): - return send_command("tabs.close", {"duplicates": True}) + def tabs_close_duplicates(self) -> int: + """Close duplicate tabs. Returns count closed.""" + result = self._cmd("tabs.close", {"duplicates": True}) + return result.get("closed", 0) if isinstance(result, dict) else 0 # ── Tab Groups ──────────────────────────────────────────────────────── - def group_list(self) -> list[dict]: - return send_command("group.list", {}) + def group_list(self) -> list[Group]: + """Return all tab groups.""" + return [self._make_group(g) for g in (self._cmd("group.list", {}) or [])] - def group_tabs(self, group_id: int) -> list[dict]: - return send_command("group.tabs", {"groupId": group_id}) + def group_tabs(self, group_id: int) -> list[Tab]: + """Return all tabs inside a group.""" + return [self._make_tab(t) for t in (self._cmd("group.tabs", {"groupId": group_id}) or [])] def group_count(self) -> int: - return send_command("group.count", {}) + """Return the number of tab groups.""" + return self._cmd("group.count", {}) - def group_query(self, search: str) -> list[dict]: - return send_command("group.query", {"search": 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 [])] - def group_close(self, group_id: int): - return send_command("group.close", {"groupId": group_id}) + def group_close(self, group_id: int) -> None: + """Ungroup (and close) a tab group by ID.""" + self._cmd("group.close", {"groupId": group_id}) - def group_open(self, name: str) -> dict: - return send_command("group.open", {"name": 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}) + return self._make_group(data) if isinstance(data, dict) else Group(id=data, title=name, color="", collapsed=False, tab_count=0) + + def group_add_tab(self, group: str | int, url: str | None = None) -> int | None: + """Open a new tab (optionally at URL) inside a group. Returns the new tab ID.""" + result = self._cmd("group.add_tab", {"group": str(group), "url": url}) + return result.get("tabId") if isinstance(result, dict) else result + + def group_move(self, group: str | int, *, forward: bool = False, backward: bool = False) -> None: + """Move a tab group forward or backward.""" + self._cmd("group.move", {"group": str(group), "forward": forward, "backward": backward}) # ── Windows ─────────────────────────────────────────────────────────── def windows_list(self) -> list[dict]: - return send_command("windows.list", {}) + return self._cmd("windows.list", {}) - def windows_rename(self, window_id: int, name: str): - return send_command("windows.rename", {"windowId": window_id, "name": name}) + def windows_rename(self, window_id: int, name: str) -> None: + self._cmd("windows.rename", {"windowId": window_id, "name": name}) - def windows_close(self, window_id: int): - return send_command("windows.close", {"windowId": window_id}) + def windows_close(self, window_id: int) -> None: + self._cmd("windows.close", {"windowId": window_id}) def windows_open(self, profile: str | None = None) -> dict: - return send_command("windows.open", {"profile": profile}) + return self._cmd("windows.open", {"profile": profile}) # ── DOM ─────────────────────────────────────────────────────────────── def dom_query(self, selector: str) -> list[dict]: - return send_command("dom.query", {"selector": selector}) + return self._cmd("dom.query", {"selector": selector}) - def dom_click(self, selector: str): - return send_command("dom.click", {"selector": selector}) + def dom_click(self, selector: str) -> None: + self._cmd("dom.click", {"selector": selector}) - def dom_type(self, selector: str, text: str): - return send_command("dom.type", {"selector": selector, "text": text}) + def dom_type(self, selector: str, text: str) -> None: + self._cmd("dom.type", {"selector": selector, "text": text}) def dom_attr(self, selector: str, attr: str) -> list[str]: - return send_command("dom.attr", {"selector": selector, "attr": attr}) + return self._cmd("dom.attr", {"selector": selector, "attr": attr}) def dom_text(self, selector: str) -> list[str]: - return send_command("dom.text", {"selector": selector}) + return self._cmd("dom.text", {"selector": selector}) def dom_exists(self, selector: str) -> bool: - return send_command("dom.exists", {"selector": selector}) + return self._cmd("dom.exists", {"selector": selector}) # ── Extract ─────────────────────────────────────────────────────────── def extract_links(self) -> list[dict]: - return send_command("extract.links", {}) + return self._cmd("extract.links", {}) def extract_images(self) -> list[dict]: - return send_command("extract.images", {}) + return self._cmd("extract.images", {}) def extract_text(self) -> str: - return send_command("extract.text", {}) + return self._cmd("extract.text", {}) def extract_json(self, selector: str): - return send_command("extract.json", {"selector": selector}) + return self._cmd("extract.json", {"selector": selector}) # ── Session ─────────────────────────────────────────────────────────── - def session_save(self, name: str): - return send_command("session.save", {"name": name}) + def session_save(self, name: str) -> None: + self._cmd("session.save", {"name": name}) - def session_load(self, name: str): - return send_command("session.load", {"name": name}) + def session_load(self, name: str) -> None: + self._cmd("session.load", {"name": name}) def session_diff(self, name_a: str, name_b: str) -> dict: - return send_command("session.diff", {"nameA": name_a, "nameB": name_b}) + return self._cmd("session.diff", {"nameA": name_a, "nameB": name_b}) def session_list(self) -> list[dict]: - return send_command("session.list", {}) + return self._cmd("session.list", {}) - def session_remove(self, name: str): - return send_command("session.remove", {"name": name}) + def session_remove(self, name: str) -> None: + self._cmd("session.remove", {"name": name}) - def session_auto_save(self, enabled: bool): - return send_command("session.auto_save", {"enabled": enabled}) + def session_auto_save(self, enabled: bool) -> None: + self._cmd("session.auto_save", {"enabled": enabled}) # ── Misc ────────────────────────────────────────────────────────────── def clients(self) -> list[dict]: - return send_command("clients.list", {}) + return self._cmd("clients.list", {}) diff --git a/browser_cli/models.py b/browser_cli/models.py new file mode 100644 index 0000000..8f566b3 --- /dev/null +++ b/browser_cli/models.py @@ -0,0 +1,131 @@ +""" +Typed dataclasses returned by the BrowserCLI Python API. + +Each object is bound to a BrowserCLI instance so you can call actions +directly on it: + + tabs = b.tabs_list() + tabs[0].close() + tabs[0].move(forward=True) + + groups = b.group_list() + groups[0].tabs() + groups[0].add_tab("https://example.com") +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from browser_cli import BrowserCLI + + +# ── Tab ─────────────────────────────────────────────────────────────────────── + +@dataclass +class Tab: + """A browser tab.""" + id: int + window_id: int + active: bool + title: str + url: str + group_id: int | None = None + _browser: BrowserCLI | None = field(default=None, repr=False, compare=False, init=False) + + def _b(self) -> BrowserCLI: + if self._browser is None: + raise RuntimeError("Tab is not bound to a BrowserCLI instance") + return self._browser + + def close(self) -> None: + """Close this tab.""" + self._b()._cmd("tabs.close", {"tabId": self.id}) + + def activate(self) -> None: + """Switch browser focus to this tab.""" + self._b()._cmd("tabs.active", {"tabId": self.id}) + + def reload(self) -> None: + """Reload this tab.""" + self._b()._cmd("navigate.reload", {"tabId": self.id}) + + def hard_reload(self) -> None: + """Hard-reload this tab (bypass cache).""" + self._b()._cmd("navigate.hard_reload", {"tabId": self.id}) + + def move( + self, *, + forward: bool = False, + backward: bool = False, + group_id: int | None = None, + window_id: int | None = None, + index: int | None = None, + ) -> None: + """Move this tab. + + Args: + forward: Move one position to the right within the window. + backward: Move one position to the left within the window. + group_id: Move into the tab group with this ID. + window_id: Move to the window with this ID. + index: Absolute position index in the target window. + """ + self._b()._cmd("tabs.move", { + "tabId": self.id, + "forward": forward, + "backward": backward, + "groupId": group_id, + "windowId": window_id, + "index": index, + }) + + def html(self) -> str: + """Return the full HTML source of this tab.""" + return self._b()._cmd("tabs.html", {"tabId": self.id}) + + def open(self, url: str, *, background: bool = False) -> None: + """Navigate this tab to *url*.""" + # Re-uses navigate.open which opens a new tab; for in-place navigation + # we target by tabId via the focus then navigate approach. For now we + # open a new tab in the same window as a convenience. + self._b()._cmd("navigate.open", {"url": url, "background": background}) + + +# ── Group ───────────────────────────────────────────────────────────────────── + +@dataclass +class Group: + """A browser tab group.""" + id: int + title: str + color: str + collapsed: bool + tab_count: int + _browser: BrowserCLI | None = field(default=None, repr=False, compare=False, init=False) + + def _b(self) -> BrowserCLI: + if self._browser is None: + raise RuntimeError("Group is not bound to a BrowserCLI instance") + return self._browser + + def close(self) -> None: + """Ungroup (and close) this tab group.""" + self._b()._cmd("group.close", {"groupId": self.id}) + + def tabs(self) -> list[Tab]: + """Return all tabs inside this group.""" + return self._b().group_tabs(self.id) + + def move(self, *, forward: bool = False, backward: bool = False) -> None: + """Move this group forward or backward among groups.""" + self._b()._cmd("group.move", { + "group": str(self.id), + "forward": forward, + "backward": backward, + }) + + def add_tab(self, url: str | None = None) -> int | None: + """Open a new tab inside this group. Returns the new tab ID.""" + return self._b().group_add_tab(self.id, url)