refactor(api): namespaced SDK + dedicated transport layer
Testing / remote-protocol-compat (0.9.3) (push) Successful in 42s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 44s
Package Extension / package-extension (push) Successful in 43s
Build & Publish Package / publish (push) Successful in 43s
Testing / test (push) Successful in 45s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 42s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 44s
Package Extension / package-extension (push) Successful in 43s
Build & Publish Package / publish (push) Successful in 43s
Testing / test (push) Successful in 45s
Restructure the Python API and internals around composable namespaces and a standalone transport/endpoint layer. Bump to 0.12.0. Python API: - Replace flat methods (b.tabs_list(), b.group_list()) with namespaces: b.nav, b.tabs, b.groups, b.windows, b.dom, b.extract, b.page, b.storage, b.cookies, b.session, b.perf, b.extension. - Shrink browser_cli/__init__.py to a thin composition root; move all behaviour into browser_cli/sdk/ (one module per namespace + factories, base, routing). Internals: - Add browser_cli/transport.py and remote_transport.py to isolate IPC from command logic; client.py now delegates instead of owning transport. - Add browser_cli/endpoints.py for endpoint resolution and browser_cli/errors.py for shared error types. - Extract markdown rendering into browser_cli/markdown.py (out of extract). - Add USER_AGENT to version_manager. Tooling & tests: - Add justfile with common dev tasks. - Update CLI commands and demo to the namespaced API. - Rework tests for the new layout; add test_transport.py and test_refactor_boundaries.py to lock in module boundaries. BREAKING CHANGE: flat API methods are removed in favour of namespaces (e.g. b.tabs_list() -> b.tabs.list(), b.group_list() -> b.groups.list()).
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
"""SDK command namespaces for :class:`browser_cli.BrowserCLI`.
|
||||
|
||||
Each namespace groups related browser commands under a short accessor on the
|
||||
client (``b.tabs``, ``b.dom``, ``b.session``, ...), mirroring the command groups
|
||||
in the browser extension.
|
||||
"""
|
||||
from browser_cli.sdk.browser_data import CookiesNS, StorageNS
|
||||
from browser_cli.sdk.dom import DomNS, ExtractNS, PageNS
|
||||
from browser_cli.sdk.extension import ExtensionNS
|
||||
from browser_cli.sdk.groups import GroupsNS
|
||||
from browser_cli.sdk.navigation import NavigationNS
|
||||
from browser_cli.sdk.perf import PerfNS
|
||||
from browser_cli.sdk.session import SessionNS
|
||||
from browser_cli.sdk.tabs import TabsNS
|
||||
from browser_cli.sdk.windows import WindowsNS
|
||||
|
||||
__all__ = [
|
||||
"NavigationNS",
|
||||
"TabsNS",
|
||||
"GroupsNS",
|
||||
"WindowsNS",
|
||||
"DomNS",
|
||||
"ExtractNS",
|
||||
"PageNS",
|
||||
"StorageNS",
|
||||
"CookiesNS",
|
||||
"SessionNS",
|
||||
"PerfNS",
|
||||
"ExtensionNS",
|
||||
]
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Base class for SDK command namespaces.
|
||||
|
||||
Each namespace (``b.tabs``, ``b.dom``, ...) is a thin object bound to its
|
||||
:class:`~browser_cli.BrowserCLI` client. Namespaces hold no state of their own;
|
||||
they delegate to the client's shared infrastructure (``_cmd``, the multi-browser
|
||||
helpers, and the ``Tab``/``Group`` factories).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from browser_cli import BrowserCLI
|
||||
|
||||
class Namespace:
|
||||
"""A group of related SDK methods, bound to a BrowserCLI client."""
|
||||
|
||||
def __init__(self, client: "BrowserCLI"):
|
||||
self._c = client
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Storage and cookies namespaces: ``b.storage.*``, ``b.cookies.*``."""
|
||||
from __future__ import annotations
|
||||
|
||||
from browser_cli.sdk.base import Namespace
|
||||
|
||||
class StorageNS(Namespace):
|
||||
"""Read and write localStorage / sessionStorage."""
|
||||
|
||||
def get(
|
||||
self,
|
||||
key: str | None = None,
|
||||
*,
|
||||
type: str = "local",
|
||||
tab_id: int | None = None,
|
||||
) -> str | dict | None:
|
||||
"""Get a localStorage/sessionStorage entry (or all entries if key omitted)."""
|
||||
return self._c._cmd("storage.get", {"key": key, "type": type, "tabId": tab_id})
|
||||
|
||||
def set(
|
||||
self,
|
||||
key: str,
|
||||
value: str,
|
||||
*,
|
||||
type: str = "local",
|
||||
tab_id: int | None = None,
|
||||
) -> None:
|
||||
"""Set a localStorage/sessionStorage entry."""
|
||||
self._c._cmd("storage.set", {"key": key, "value": value, "type": type, "tabId": tab_id})
|
||||
|
||||
class CookiesNS(Namespace):
|
||||
"""List, get, and set cookies."""
|
||||
|
||||
def list(
|
||||
self,
|
||||
*,
|
||||
url: str | None = None,
|
||||
domain: str | None = None,
|
||||
name: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""List cookies, optionally filtered by url, domain, or name."""
|
||||
return self._c._cmd("cookies.list", {"url": url, "domain": domain, "name": name}) or []
|
||||
|
||||
def get(self, url: str, name: str) -> dict | None:
|
||||
"""Get a single cookie by url and name."""
|
||||
return self._c._cmd("cookies.get", {"url": url, "name": name})
|
||||
|
||||
def set(
|
||||
self,
|
||||
url: str,
|
||||
name: str,
|
||||
value: str,
|
||||
*,
|
||||
domain: str | None = None,
|
||||
path: str | None = None,
|
||||
secure: bool | None = None,
|
||||
http_only: bool | None = None,
|
||||
expiration_date: float | None = None,
|
||||
same_site: str | None = None,
|
||||
) -> dict:
|
||||
"""Set a cookie. Returns the created cookie dict."""
|
||||
return self._c._cmd("cookies.set", {
|
||||
"url": url, "name": name, "value": value,
|
||||
"domain": domain, "path": path,
|
||||
"secure": secure, "httpOnly": http_only,
|
||||
"expirationDate": expiration_date, "sameSite": same_site,
|
||||
})
|
||||
@@ -0,0 +1,150 @@
|
||||
"""DOM, content-extraction, and page-info namespaces: ``b.dom.*``, ``b.extract.*``, ``b.page.*``."""
|
||||
from __future__ import annotations
|
||||
|
||||
from browser_cli.sdk.base import Namespace
|
||||
|
||||
class DomNS(Namespace):
|
||||
"""Query and drive page elements in the active (or specified) tab."""
|
||||
|
||||
def query(self, selector: str) -> list[dict]:
|
||||
return self._c._cmd("dom.query", {"selector": selector}) or []
|
||||
|
||||
def click(self, selector: str) -> None:
|
||||
self._c._cmd("dom.click", {"selector": selector})
|
||||
|
||||
def type(self, selector: str, text: str) -> None:
|
||||
self._c._cmd("dom.type", {"selector": selector, "text": text})
|
||||
|
||||
def attr(self, selector: str, attr: str) -> list[str]:
|
||||
return self._c._cmd("dom.attr", {"selector": selector, "attr": attr}) or []
|
||||
|
||||
def text(self, selector: str) -> list[str]:
|
||||
return self._c._cmd("dom.text", {"selector": selector}) or []
|
||||
|
||||
def exists(self, selector: str) -> bool:
|
||||
return self._c._cmd("dom.exists", {"selector": selector}) or False
|
||||
|
||||
def scroll(self, selector: str | None = None, *, x: int | None = None, y: int | None = None) -> None:
|
||||
"""Scroll to a CSS selector or to pixel coordinates."""
|
||||
self._c._cmd("dom.scroll", {"selector": selector, "x": x, "y": y})
|
||||
|
||||
def select(self, selector: str, value: str) -> None:
|
||||
"""Set the value of a <select> element."""
|
||||
self._c._cmd("dom.select", {"selector": selector, "value": value})
|
||||
|
||||
def eval(self, code: str, tab_id: int | None = None):
|
||||
"""Evaluate JavaScript in the page's main world and return the result."""
|
||||
return self._c._cmd("dom.eval", {"code": code, "tabId": tab_id})
|
||||
|
||||
def key(self, key: str, selector: str | None = None) -> None:
|
||||
"""Dispatch a keyboard event. key examples: 'Enter', 'Tab', 'Escape', 'ArrowDown'."""
|
||||
self._c._cmd("dom.key", {"key": key, "selector": selector})
|
||||
|
||||
def hover(self, selector: str) -> None:
|
||||
"""Dispatch mouseover/mouseenter on an element."""
|
||||
self._c._cmd("dom.hover", {"selector": selector})
|
||||
|
||||
def check(self, selector: str) -> None:
|
||||
"""Check a checkbox."""
|
||||
self._c._cmd("dom.check", {"selector": selector})
|
||||
|
||||
def uncheck(self, selector: str) -> None:
|
||||
"""Uncheck a checkbox."""
|
||||
self._c._cmd("dom.uncheck", {"selector": selector})
|
||||
|
||||
def clear(self, selector: str) -> None:
|
||||
"""Clear the value of an input element."""
|
||||
self._c._cmd("dom.clear", {"selector": selector})
|
||||
|
||||
def focus(self, selector: str) -> None:
|
||||
"""Focus an element."""
|
||||
self._c._cmd("dom.focus", {"selector": selector})
|
||||
|
||||
def submit(self, selector: str) -> None:
|
||||
"""Submit the form containing the matched element."""
|
||||
self._c._cmd("dom.submit", {"selector": selector})
|
||||
|
||||
def poll(
|
||||
self,
|
||||
selector: str,
|
||||
pattern: str,
|
||||
*,
|
||||
attr: str | None = None,
|
||||
timeout: float = 30.0,
|
||||
interval: float = 0.5,
|
||||
tab_id: int | None = None,
|
||||
) -> dict:
|
||||
"""Poll selector's text/value until it matches regex pattern.
|
||||
|
||||
Returns ``{"selector": ..., "value": ..., "pattern": ...}`` when matched.
|
||||
"""
|
||||
return self._c._cmd("dom.poll", {
|
||||
"selector": selector,
|
||||
"pattern": pattern,
|
||||
"attr": attr,
|
||||
"timeout": int(timeout * 1000),
|
||||
"interval": int(interval * 1000),
|
||||
"tabId": tab_id,
|
||||
})
|
||||
|
||||
def wait_for(
|
||||
self,
|
||||
selector: str,
|
||||
*,
|
||||
timeout: float = 10.0,
|
||||
visible: bool = False,
|
||||
hidden: bool = False,
|
||||
tab_id: int | None = None,
|
||||
) -> dict:
|
||||
"""Wait until a CSS selector appears (or disappears) in the DOM.
|
||||
|
||||
Args:
|
||||
selector: CSS selector to watch.
|
||||
timeout: Max seconds to wait before raising ``RuntimeError``.
|
||||
visible: Wait until the element has non-zero dimensions.
|
||||
hidden: Wait until the element is absent or has ``offsetParent == null``.
|
||||
tab_id: Tab to watch. Defaults to the active tab.
|
||||
"""
|
||||
return self._c._cmd("dom.wait_for", {
|
||||
"selector": selector,
|
||||
"timeout": int(timeout * 1000),
|
||||
"visible": visible,
|
||||
"hidden": hidden,
|
||||
"tabId": tab_id,
|
||||
})
|
||||
|
||||
class ExtractNS(Namespace):
|
||||
"""Extract structured content from the active tab."""
|
||||
|
||||
def links(self) -> list[dict]:
|
||||
return self._c._cmd("extract.links", {}) or []
|
||||
|
||||
def images(self) -> list[dict]:
|
||||
return self._c._cmd("extract.images", {}) or []
|
||||
|
||||
def text(self) -> str:
|
||||
return self._c._cmd("extract.text", {}) or ""
|
||||
|
||||
def json(self, selector: str):
|
||||
return self._c._cmd("extract.json", {"selector": selector})
|
||||
|
||||
def html(self) -> str:
|
||||
"""Return the full HTML source of the active tab."""
|
||||
return self._c._cmd("extract.html", {}) or ""
|
||||
|
||||
def markdown(self, selector: str | None = None) -> str:
|
||||
"""Extract the page's main content as clean Markdown.
|
||||
|
||||
The extractor may return either Markdown or raw HTML; both are
|
||||
normalized to Markdown here so SDK and CLI callers get identical output.
|
||||
"""
|
||||
from browser_cli.markdown import render_markdown
|
||||
|
||||
return render_markdown(self._c._cmd("extract.markdown", {"selector": selector}))
|
||||
|
||||
class PageNS(Namespace):
|
||||
"""Inspect the active page."""
|
||||
|
||||
def info(self) -> dict:
|
||||
"""Return title, URL, readyState, lang, and meta tags of the active tab."""
|
||||
return self._c._cmd("page.info", {}) or {}
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Extension-control namespace: ``b.extension.*``."""
|
||||
from __future__ import annotations
|
||||
|
||||
from browser_cli.sdk.base import Namespace
|
||||
|
||||
class ExtensionNS(Namespace):
|
||||
"""Control the browser-cli extension itself."""
|
||||
|
||||
def reload(self) -> None:
|
||||
"""Reload the browser-cli extension service worker.
|
||||
|
||||
Schedules a ``chrome.runtime.reload()`` inside the extension and returns
|
||||
immediately. The extension restarts ~200 ms later and reconnects via the
|
||||
keepalive alarm within ~25 seconds.
|
||||
"""
|
||||
self._c._cmd("extension.reload", {})
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Object-factory mixin for :class:`~browser_cli.BrowserCLI`.
|
||||
|
||||
Builds the typed :class:`~browser_cli.models.Tab` / :class:`~browser_cli.models.Group`
|
||||
dataclasses from raw command responses and binds each one to the client that
|
||||
should run its actions. In multi-browser mode an object is bound to a sibling
|
||||
client targeting the browser it came from, so ``tab.close()`` routes correctly.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from browser_cli.models import Group, Tab
|
||||
|
||||
class FactoryMixin:
|
||||
"""Turn raw response dicts into bound ``Tab``/``Group`` objects.
|
||||
|
||||
Mixed into :class:`~browser_cli.BrowserCLI`; relies on the client providing
|
||||
``_browser``/``_remote``/``_key`` and being constructible via ``type(self)``.
|
||||
"""
|
||||
|
||||
def _make_tab(
|
||||
self,
|
||||
data: dict,
|
||||
*,
|
||||
browser_profile: str | None = None,
|
||||
browser_name: str | None = None,
|
||||
browser_remote: str | None = None,
|
||||
) -> Tab:
|
||||
tab = Tab(
|
||||
id=data["id"],
|
||||
window_id=data.get("windowId", 0),
|
||||
active=data.get("active", False),
|
||||
muted=data.get("muted", False),
|
||||
title=data.get("title") or "",
|
||||
url=data.get("url") or "",
|
||||
group_id=data.get("groupId") or None,
|
||||
browser=browser_name,
|
||||
)
|
||||
tab._browser = self if browser_profile is None else type(self)(
|
||||
browser=browser_profile,
|
||||
remote=browser_remote,
|
||||
key=self._key,
|
||||
)
|
||||
return tab
|
||||
|
||||
def _require_tab(self, data, error: str) -> Tab:
|
||||
"""Build a bound Tab from a tab-shaped response, or raise ``RuntimeError(error)``."""
|
||||
if not isinstance(data, dict) or "id" not in data:
|
||||
raise RuntimeError(error)
|
||||
return self._make_tab(data)
|
||||
|
||||
def _make_group(
|
||||
self,
|
||||
data: dict,
|
||||
*,
|
||||
browser_profile: str | None = None,
|
||||
browser_name: str | None = None,
|
||||
browser_remote: str | None = None,
|
||||
) -> 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),
|
||||
browser=browser_name,
|
||||
)
|
||||
group._browser = self if browser_profile is None else type(self)(
|
||||
browser=browser_profile,
|
||||
remote=browser_remote,
|
||||
key=self._key,
|
||||
)
|
||||
return group
|
||||
|
||||
def _make_tab_for(self, data: dict, target) -> Tab:
|
||||
"""Build a Tab, tagging it with *target* in multi-browser mode (``None`` = local)."""
|
||||
return self._make_tab(
|
||||
data,
|
||||
browser_profile=target.profile if target else None,
|
||||
browser_name=target.display_name if target else None,
|
||||
browser_remote=target.remote if target else None,
|
||||
)
|
||||
|
||||
def _make_group_for(self, data: dict, target) -> Group:
|
||||
"""Build a Group, tagging it with *target* in multi-browser mode (``None`` = local)."""
|
||||
return self._make_group(
|
||||
data,
|
||||
browser_profile=target.profile if target else None,
|
||||
browser_name=target.display_name if target else None,
|
||||
browser_remote=target.remote if target else None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _tag_browser(item: dict, target) -> dict:
|
||||
"""Return *item* as-is locally, or with a ``browser`` key in multi-browser mode."""
|
||||
return item if target is None else {**item, "browser": target.display_name}
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Tab groups namespace: ``b.groups.*``."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from browser_cli.models import Group, Tab
|
||||
from browser_cli.sdk.base import Namespace
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from browser_cli import BrowserCounts
|
||||
|
||||
class GroupsNS(Namespace):
|
||||
"""List, create, query, and modify tab groups."""
|
||||
|
||||
def list(self) -> list[Group]:
|
||||
"""Return all tab groups.
|
||||
|
||||
When multiple browsers are active and no browser was specified, each Group
|
||||
includes ``group.browser`` naming its source browser.
|
||||
"""
|
||||
return self._c._multi_list("group.list", {}, self._c._make_group_for)
|
||||
|
||||
def count(self) -> "int | BrowserCounts":
|
||||
"""Return the number of tab groups.
|
||||
|
||||
Returns ``BrowserCounts`` in implicit multi-browser mode.
|
||||
"""
|
||||
return self._c._multi_count("group.count", {})
|
||||
|
||||
def query(self, search: str) -> list[Group]:
|
||||
"""Search groups by name."""
|
||||
return [self._c._make_group(g) for g in (self._c._cmd("group.query", {"search": search}) or [])]
|
||||
|
||||
def create(self, name: str) -> Group:
|
||||
"""Create a new tab group with *name*. Returns the created Group."""
|
||||
data = self._c._cmd("group.open", {"name": name})
|
||||
if isinstance(data, dict):
|
||||
return self._c._make_group(data)
|
||||
return Group(id=data, title=name, color="", collapsed=False, tab_count=0)
|
||||
|
||||
def tabs(self, group_id: int) -> list[Tab]:
|
||||
"""Return all tabs inside a group."""
|
||||
return [self._c._make_tab(t) for t in (self._c._cmd("group.tabs", {"groupId": group_id}) or [])]
|
||||
|
||||
def 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._c._cmd("group.add_tab", {"group": str(group), "url": url})
|
||||
return self._c._field(result, "tabId", fallback=result)
|
||||
|
||||
def move(self, group: str | int, *, forward: bool = False, backward: bool = False) -> dict | None:
|
||||
"""Move a tab group forward or backward. Returns the raw move result."""
|
||||
return self._c._cmd("group.move", {"group": str(group), "forward": forward, "backward": backward})
|
||||
|
||||
def close(self, group_id: int, *, gentle_mode: str = "auto") -> None:
|
||||
"""Ungroup (and close) a tab group by ID."""
|
||||
self._c._cmd("group.close", {"groupId": group_id, "gentleMode": gentle_mode})
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Navigation namespace: ``b.nav.*``."""
|
||||
from __future__ import annotations
|
||||
|
||||
from browser_cli.models import Tab
|
||||
from browser_cli.sdk.base import Namespace
|
||||
|
||||
class NavigationNS(Namespace):
|
||||
"""Open URLs, navigate history, and focus tabs."""
|
||||
|
||||
def open(self, url: str, *, background: bool = False, window: str | None = None, group: str | None = None) -> None:
|
||||
"""Open *url* in a new tab."""
|
||||
self._c._cmd("navigate.open", {"url": url, "background": background, "window": window, "group": group})
|
||||
|
||||
def open_wait(
|
||||
self,
|
||||
url: str,
|
||||
*,
|
||||
timeout: float = 30.0,
|
||||
background: bool = False,
|
||||
window: str | None = None,
|
||||
group: str | None = None,
|
||||
) -> Tab:
|
||||
"""Open *url* in a new tab and block until fully loaded. Returns the Tab."""
|
||||
return self._c._require_tab(
|
||||
self._c._cmd("navigate.open_wait", {
|
||||
"url": url, "timeout": int(timeout * 1000),
|
||||
"background": background, "window": window, "group": group,
|
||||
}),
|
||||
"navigate.open_wait returned unexpected data",
|
||||
)
|
||||
|
||||
def reload(self, tab_id: int | None = None) -> None:
|
||||
self._c._cmd("navigate.reload", {"tabId": tab_id})
|
||||
|
||||
def hard_reload(self, tab_id: int | None = None) -> None:
|
||||
self._c._cmd("navigate.hard_reload", {"tabId": tab_id})
|
||||
|
||||
def back(self, tab_id: int | None = None) -> None:
|
||||
self._c._cmd("navigate.back", {"tabId": tab_id})
|
||||
|
||||
def forward(self, tab_id: int | None = None) -> None:
|
||||
self._c._cmd("navigate.forward", {"tabId": tab_id})
|
||||
|
||||
def focus(self, pattern: str) -> dict | None:
|
||||
"""Focus the first tab whose URL matches *pattern*. Returns the matched tab info, if any."""
|
||||
return self._c._cmd("navigate.focus", {"pattern": pattern})
|
||||
|
||||
def to(self, tab_id: int, url: str) -> None:
|
||||
"""Navigate a specific tab to *url* in place."""
|
||||
self._c._cmd("navigate.to", {"tabId": tab_id, "url": url})
|
||||
|
||||
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._c._cmd("navigate.open", {"url": url, "background": background, "window": window, "group": group})
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Performance and background-jobs namespace: ``b.perf.*``."""
|
||||
from __future__ import annotations
|
||||
|
||||
from browser_cli.sdk.base import Namespace
|
||||
|
||||
class PerfNS(Namespace):
|
||||
"""Inspect the performance profile and manage background jobs."""
|
||||
|
||||
def status(self) -> dict:
|
||||
return self._c._cmd("perf.status", {}) or {}
|
||||
|
||||
def set_profile(self, profile: str) -> dict:
|
||||
return self._c._cmd("perf.set_profile", {"profile": profile}) or {}
|
||||
|
||||
def job_status(self, job_id: str) -> dict:
|
||||
return self._c._cmd("jobs.status", {"jobId": job_id}) or {}
|
||||
|
||||
def job_cancel(self, job_id: str) -> dict:
|
||||
return self._c._cmd("jobs.cancel", {"jobId": job_id}) or {}
|
||||
@@ -0,0 +1,123 @@
|
||||
"""Multi-browser routing mixin for :class:`~browser_cli.BrowserCLI`.
|
||||
|
||||
When no specific browser is selected and more than one browser (local or
|
||||
remote) is active, list/count commands fan out to every target and aggregate
|
||||
the results. This mixin holds that fan-out machinery plus a few small response
|
||||
helpers; single-browser mode falls straight through to ``_cmd``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
|
||||
import browser_cli as _pkg
|
||||
from browser_cli.client import BrowserNotConnected
|
||||
from browser_cli.models import BrowserCounts, Tab
|
||||
|
||||
# send_command / active_browser_targets / remote_browser_targets are resolved
|
||||
# through the ``browser_cli`` package namespace (``_pkg``) at call time, not bound
|
||||
# here at import, so tests patching ``browser_cli.send_command`` still take effect.
|
||||
|
||||
_UNSET = object()
|
||||
|
||||
class RoutingMixin:
|
||||
"""Fan-out + aggregation across active browsers, mixed into ``BrowserCLI``.
|
||||
|
||||
Relies on the client exposing ``_browser``/``_remote``/``_key``, ``_cmd``,
|
||||
and a ``tabs`` namespace.
|
||||
"""
|
||||
|
||||
def _multi_browser_targets(self):
|
||||
if self._browser is not None:
|
||||
return []
|
||||
if self._remote:
|
||||
targets = _pkg.remote_browser_targets(self._remote, key=self._key)
|
||||
else:
|
||||
targets = _pkg.active_browser_targets()
|
||||
if len(targets) <= 1 and not any(target.remote for target in targets):
|
||||
return []
|
||||
return targets
|
||||
|
||||
def _collect_multi_browser(self, command: str, args: dict | None = None):
|
||||
results = []
|
||||
targets = self._multi_browser_targets()
|
||||
for target in targets:
|
||||
try:
|
||||
if target.remote:
|
||||
data = _pkg.send_command(command, args, profile=target.profile, remote=target.remote, key=self._key)
|
||||
else:
|
||||
data = _pkg.send_command(command, args, profile=target.profile)
|
||||
except (BrowserNotConnected, RuntimeError):
|
||||
continue
|
||||
results.append((target, data))
|
||||
if results:
|
||||
return results
|
||||
if targets:
|
||||
raise BrowserNotConnected(
|
||||
"Cannot resolve a browser socket automatically.\n"
|
||||
"Make sure the browser is running with the browser-cli extension enabled,\n"
|
||||
"or pass --browser <alias> / set BROWSER_CLI_PROFILE to a known alias."
|
||||
)
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _field(result, key, default=None, *, fallback=_UNSET):
|
||||
"""Pull *key* out of a dict response, with a non-dict fallback.
|
||||
|
||||
Returns ``result[key]`` (or *default*) when *result* is a dict. When it
|
||||
is not a dict, returns *fallback* if given, else *default*.
|
||||
"""
|
||||
if isinstance(result, dict):
|
||||
return result.get(key, default)
|
||||
return default if fallback is _UNSET else fallback
|
||||
|
||||
def _toggle_tab(self, command: str, tab_id: int | None) -> int:
|
||||
"""Run a tab toggle command (mute/pin/...) and return the target tab ID."""
|
||||
result = self._cmd(command, {"tabId": tab_id})
|
||||
return self._field(result, "tabId", tab_id, fallback=int(tab_id or 0))
|
||||
|
||||
def _multi_count(self, command: str, args: dict | None = None) -> "int | BrowserCounts":
|
||||
"""Count command that aggregates into :class:`BrowserCounts` in multi-browser mode."""
|
||||
multi_results = self._collect_multi_browser(command, args or {})
|
||||
if not multi_results:
|
||||
return self._cmd(command, args or {})
|
||||
by_browser = {target.display_name: int(count or 0) for target, count in multi_results}
|
||||
return BrowserCounts(total=sum(by_browser.values()), by_browser=by_browser)
|
||||
|
||||
def _multi_list(self, command: str, args: dict | None, mapper):
|
||||
"""List command, flattening per-browser results in multi-browser mode.
|
||||
|
||||
*mapper* is ``(item, target) -> mapped`` where ``target`` is the source
|
||||
:class:`BrowserTarget` in multi mode, or ``None`` in single-browser mode.
|
||||
"""
|
||||
multi_results = self._collect_multi_browser(command, args or {})
|
||||
if multi_results:
|
||||
return [
|
||||
mapper(item, target)
|
||||
for target, items in multi_results
|
||||
for item in (items or [])
|
||||
]
|
||||
return [mapper(item, None) for item in (self._cmd(command, args or {}) or [])]
|
||||
|
||||
def _apply_tab_filter(self, filter_fn: Callable[[Tab], bool] | Callable[[list[Tab]], Iterable[Tab]]) -> list[Tab]:
|
||||
tabs = self.tabs.list()
|
||||
|
||||
try:
|
||||
transformed = filter_fn(tabs)
|
||||
except (AttributeError, TypeError):
|
||||
return [tab for tab in tabs if filter_fn(tab)]
|
||||
|
||||
if isinstance(transformed, list):
|
||||
return transformed
|
||||
if isinstance(transformed, tuple):
|
||||
return list(transformed)
|
||||
if isinstance(transformed, set):
|
||||
return list(transformed)
|
||||
if transformed is tabs:
|
||||
return tabs
|
||||
if isinstance(transformed, bool):
|
||||
return [tab for tab in tabs if filter_fn(tab)]
|
||||
|
||||
try:
|
||||
return list(transformed)
|
||||
except TypeError:
|
||||
return [tab for tab in tabs if filter_fn(tab)]
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Session namespace: ``b.session.*``."""
|
||||
from __future__ import annotations
|
||||
|
||||
from browser_cli.sdk.base import Namespace
|
||||
|
||||
class SessionNS(Namespace):
|
||||
"""Save, restore, list, and diff browser sessions."""
|
||||
|
||||
def save(self, name: str) -> dict:
|
||||
"""Save all current tabs as session *name*. Returns the save result (incl. tab count)."""
|
||||
return self._c._cmd("session.save", {"name": name}) or {}
|
||||
|
||||
@staticmethod
|
||||
def _load_args(name, gentle_mode, discard_background_tabs, lazy, eager_tabs) -> dict:
|
||||
return {
|
||||
"name": name,
|
||||
"gentleMode": gentle_mode,
|
||||
"discardBackgroundTabs": discard_background_tabs,
|
||||
"lazy": lazy,
|
||||
"eagerTabs": eager_tabs,
|
||||
}
|
||||
|
||||
def load(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
gentle_mode: str = "auto",
|
||||
discard_background_tabs: bool = False,
|
||||
lazy: bool = False,
|
||||
eager_tabs: int = 10,
|
||||
) -> dict:
|
||||
"""Restore session *name*. Returns the load result (incl. tabs opened)."""
|
||||
args = self._load_args(name, gentle_mode, discard_background_tabs, lazy, eager_tabs)
|
||||
return self._c._cmd("session.load", args) or {}
|
||||
|
||||
def load_background(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
gentle_mode: str = "auto",
|
||||
discard_background_tabs: bool = False,
|
||||
lazy: bool = False,
|
||||
eager_tabs: int = 10,
|
||||
) -> dict:
|
||||
"""Restore session *name* as a background job. Returns the job descriptor."""
|
||||
args = self._load_args(name, gentle_mode, discard_background_tabs, lazy, eager_tabs)
|
||||
return self._c._cmd("session.load", {**args, "__background": True}) or {}
|
||||
|
||||
def diff(self, name_a: str, name_b: str) -> dict:
|
||||
return self._c._cmd("session.diff", {"nameA": name_a, "nameB": name_b}) or {}
|
||||
|
||||
def list(self) -> list[dict]:
|
||||
"""Return saved sessions.
|
||||
|
||||
In implicit multi-browser mode each session dict includes a ``browser`` key.
|
||||
"""
|
||||
return self._c._multi_list("session.list", {}, self._c._tag_browser)
|
||||
|
||||
def remove(self, name: str) -> None:
|
||||
self._c._cmd("session.remove", {"name": name})
|
||||
|
||||
def auto_save(self, enabled: bool) -> None:
|
||||
self._c._cmd("session.auto_save", {"enabled": enabled})
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Tabs namespace: ``b.tabs.*``."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from browser_cli.models import Tab
|
||||
from browser_cli.sdk.base import Namespace
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from browser_cli import BrowserCounts
|
||||
|
||||
class TabsNS(Namespace):
|
||||
"""List, open, close, move, and inspect browser tabs."""
|
||||
|
||||
def list(self) -> list[Tab]:
|
||||
"""Return all open tabs across all windows.
|
||||
|
||||
When multiple browsers are active and no browser was specified, each Tab
|
||||
includes ``tab.browser`` naming its source browser.
|
||||
"""
|
||||
return self._c._multi_list("tabs.list", {}, self._c._make_tab_for)
|
||||
|
||||
def open(
|
||||
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`.
|
||||
|
||||
Set ``wait=True`` to block until the page reaches ``readyState=complete``.
|
||||
"""
|
||||
if wait:
|
||||
return self._c.nav.open_wait(url, timeout=timeout, background=background, window=window, group=group)
|
||||
return self._c._require_tab(
|
||||
self._c._cmd("navigate.open", {"url": url, "background": background, "window": window, "group": group}),
|
||||
"navigate.open returned unexpected data",
|
||||
)
|
||||
|
||||
def get(self, tab_id: int) -> Tab:
|
||||
"""Return a specific tab by ID."""
|
||||
return self.status(tab_id)
|
||||
|
||||
def active(self) -> Tab:
|
||||
"""Return the active tab."""
|
||||
return self.status()
|
||||
|
||||
def query(self, search: str) -> list[Tab]:
|
||||
"""Search tabs by URL or title."""
|
||||
return [self._c._make_tab(t) for t in (self._c._cmd("tabs.query", {"search": search}) or [])]
|
||||
|
||||
def first(self, search: str) -> Tab | None:
|
||||
"""Return the first tab matching *search*, or ``None``."""
|
||||
matches = self.query(search)
|
||||
return matches[0] if matches else None
|
||||
|
||||
def close(
|
||||
self,
|
||||
tab_id: int | None = None,
|
||||
*,
|
||||
tab_ids: Iterable[int | Tab] | None = None,
|
||||
inactive: bool = False,
|
||||
duplicates: bool = False,
|
||||
gentle_mode: str = "auto",
|
||||
) -> int:
|
||||
"""Close tab(s). Returns the number of tabs closed.
|
||||
|
||||
Pass ``tab_ids`` to close many tabs in a single round-trip. Accepts tab
|
||||
IDs or :class:`Tab` objects. ``gentle_mode`` (auto/normal/gentle/ultra)
|
||||
controls throttling of large close operations.
|
||||
"""
|
||||
ids = None
|
||||
if tab_ids is not None:
|
||||
ids = [t.id if isinstance(t, Tab) else t for t in tab_ids]
|
||||
result = self._c._cmd("tabs.close", {
|
||||
"tabId": tab_id,
|
||||
"tabIds": ids,
|
||||
"inactive": inactive,
|
||||
"duplicates": duplicates,
|
||||
"gentleMode": gentle_mode,
|
||||
})
|
||||
return self._c._field(result, "closed", 1)
|
||||
|
||||
def close_inactive(self) -> int:
|
||||
"""Close all inactive tabs. Returns count closed."""
|
||||
return self._c._field(self._c._cmd("tabs.close", {"inactive": True}), "closed", 0)
|
||||
|
||||
def close_duplicates(self) -> int:
|
||||
"""Close duplicate tabs. Returns count closed."""
|
||||
return self._c._field(self._c._cmd("tabs.close", {"duplicates": True}), "closed", 0)
|
||||
|
||||
def 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._c._cmd("tabs.move", {
|
||||
"tabId": tab_id, "forward": forward, "backward": backward,
|
||||
"groupId": group_id, "windowId": window_id, "index": index,
|
||||
})
|
||||
|
||||
def activate(self, tab_id: int) -> None:
|
||||
"""Switch browser focus to a tab by ID."""
|
||||
self._c._cmd("tabs.active", {"tabId": tab_id})
|
||||
|
||||
def status(self, tab_id: int | None = None) -> Tab:
|
||||
"""Return status for the active tab or a specific tab."""
|
||||
return self._c._require_tab(self._c._cmd("tabs.status", {"tabId": tab_id}), "No tab status returned")
|
||||
|
||||
def mute(self, tab_id: int | None = None) -> int:
|
||||
"""Mute the active tab or a specific tab. Returns the target tab ID."""
|
||||
return self._c._toggle_tab("tabs.mute", tab_id)
|
||||
|
||||
def unmute(self, tab_id: int | None = None) -> int:
|
||||
"""Unmute the active tab or a specific tab. Returns the target tab ID."""
|
||||
return self._c._toggle_tab("tabs.unmute", tab_id)
|
||||
|
||||
def pin(self, tab_id: int | None = None) -> int:
|
||||
"""Pin the active tab or a specific tab. Returns the target tab ID."""
|
||||
return self._c._toggle_tab("tabs.pin", tab_id)
|
||||
|
||||
def unpin(self, tab_id: int | None = None) -> int:
|
||||
"""Unpin the active tab or a specific tab. Returns the target tab ID."""
|
||||
return self._c._toggle_tab("tabs.unpin", tab_id)
|
||||
|
||||
def watch_url(self, pattern: str, *, tab_id: int | None = None, timeout: float = 30.0) -> Tab:
|
||||
"""Block until the tab URL matches regex pattern. Returns the Tab."""
|
||||
return self._c._require_tab(
|
||||
self._c._cmd("tabs.watch_url", {"pattern": pattern, "tabId": tab_id, "timeout": int(timeout * 1000)}),
|
||||
"tabs.watch_url returned unexpected data",
|
||||
)
|
||||
|
||||
def wait_for_load(
|
||||
self,
|
||||
tab_id: int | None = None,
|
||||
*,
|
||||
timeout: float = 30.0,
|
||||
ready_state: str = "complete",
|
||||
) -> Tab:
|
||||
"""Block until the tab finishes loading. Returns the Tab when ready.
|
||||
|
||||
Args:
|
||||
tab_id: Tab to watch. Defaults to the active tab.
|
||||
timeout: Max seconds to wait before raising ``RuntimeError``.
|
||||
ready_state: ``"complete"`` (default) or ``"interactive"``.
|
||||
"""
|
||||
return self._c._require_tab(
|
||||
self._c._cmd("navigate.wait", {
|
||||
"tabId": tab_id,
|
||||
"timeout": int(timeout * 1000),
|
||||
"readyState": ready_state,
|
||||
}),
|
||||
"navigate.wait returned unexpected data",
|
||||
)
|
||||
|
||||
def screenshot(
|
||||
self,
|
||||
tab_id: int | None = None,
|
||||
*,
|
||||
format: str = "png",
|
||||
quality: int | None = None,
|
||||
) -> str:
|
||||
"""Capture the visible area of a tab. Returns a base64 data URL.
|
||||
|
||||
Args:
|
||||
tab_id: Tab to capture. Defaults to the active tab.
|
||||
format: ``"png"`` (default) or ``"jpeg"``.
|
||||
quality: JPEG quality 0-100 (ignored for PNG).
|
||||
"""
|
||||
result = self._c._cmd("tabs.screenshot", {"tabId": tab_id, "format": format, "quality": quality})
|
||||
return self._c._field(result, "dataUrl", "", fallback=str(result))
|
||||
|
||||
def active_in_window(self, window_id: int) -> Tab:
|
||||
"""Return active tab for a specific browser window."""
|
||||
return self._c._require_tab(
|
||||
self._c._cmd("tabs.active_in_window", {"windowId": window_id}),
|
||||
f"No active tab found for window {window_id}",
|
||||
)
|
||||
|
||||
def filter(
|
||||
self,
|
||||
pattern_or_filter: str | Callable[[Tab], bool] | Callable[[list[Tab]], Iterable[Tab]],
|
||||
) -> list[Tab]:
|
||||
"""Return tabs filtered by URL pattern or a Python callable."""
|
||||
if isinstance(pattern_or_filter, str):
|
||||
return [self._c._make_tab(t) for t in (self._c._cmd("tabs.filter", {"pattern": pattern_or_filter}) or [])]
|
||||
return self._c._apply_tab_filter(pattern_or_filter)
|
||||
|
||||
def count(self, pattern: str | None = None) -> "int | BrowserCounts":
|
||||
"""Count open tabs, optionally filtered by URL pattern.
|
||||
|
||||
Returns ``BrowserCounts`` in implicit multi-browser mode.
|
||||
"""
|
||||
return self._c._multi_count("tabs.count", {"pattern": pattern})
|
||||
|
||||
def html(self, tab_id: int | None = None) -> str:
|
||||
"""Return the full HTML source of the active (or specified) tab."""
|
||||
return self._c._cmd("tabs.html", {"tabId": tab_id}) or ""
|
||||
|
||||
def dedupe(self, *, gentle_mode: str = "auto") -> int:
|
||||
"""Close duplicate tabs (keep the first occurrence of each URL). Returns count closed."""
|
||||
return self._c._field(self._c._cmd("tabs.dedupe", {"gentleMode": gentle_mode}), "closed", 0)
|
||||
|
||||
def sort(self, by: str = "domain", *, gentle_mode: str = "auto") -> None:
|
||||
"""Sort tabs within each window. *by* is one of 'domain', 'title', 'time'."""
|
||||
self._c._cmd("tabs.sort", {"by": by, "gentleMode": gentle_mode})
|
||||
|
||||
def merge_windows(self, *, gentle_mode: str = "auto") -> int:
|
||||
"""Move all tabs into the focused window. Returns count moved."""
|
||||
return self._c._field(self._c._cmd("tabs.merge_windows", {"gentleMode": gentle_mode}), "moved", 0)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Windows namespace: ``b.windows.*``."""
|
||||
from __future__ import annotations
|
||||
|
||||
from browser_cli.sdk.base import Namespace
|
||||
|
||||
class WindowsNS(Namespace):
|
||||
"""List, open, close, and rename browser windows."""
|
||||
|
||||
def list(self) -> list[dict]:
|
||||
"""Return browser windows.
|
||||
|
||||
In implicit multi-browser mode each window dict includes a ``browser`` key.
|
||||
"""
|
||||
return self._c._multi_list("windows.list", {}, self._c._tag_browser)
|
||||
|
||||
def open(self, url: str | None = None) -> dict:
|
||||
"""Open a new browser window, optionally on a URL."""
|
||||
return self._c._cmd("windows.open", {"url": url}) or {}
|
||||
|
||||
def close(self, window_id: int) -> None:
|
||||
self._c._cmd("windows.close", {"windowId": window_id})
|
||||
|
||||
def rename(self, window_id: int, name: str) -> None:
|
||||
self._c._cmd("windows.rename", {"windowId": window_id, "name": name})
|
||||
Reference in New Issue
Block a user