Files
browser-cli/browser_cli/async_sdk.py
T
daniel156161 7cb2a8b618
Testing / remote-protocol-compat (0.9.5) (push) Successful in 1m4s
Testing / test (push) Successful in 1m22s
Testing / remote-protocol-compat (0.9.3) (push) Successful in 1m7s
Package Extension / package-extension (push) Successful in 1m1s
Build & Publish Package / publish (push) Successful in 1m5s
refactor: modularize auth transport and markdown
- Split auth into focused package modules for agent keys, file keys,
  signing, and post-quantum transport helpers while keeping the public
  browser_cli.auth import surface intact.
- Move transport encoding internals into a package with separate codec and
  binary-hoisting helpers, preserving browser_cli.transport compatibility.
- Extract remote TCP auth/socket helpers and serve challenge setup out of the
  runtime paths to make connection handling easier to reason about.
- Move the extension markdown extractor into a dedicated content/markdown
  folder with separate root selection, code normalization, renderer, and utils.
- Centralize CLI Rich rendering helpers for tab/window tree and table output,
  and add rendering tests for the shared builders.
- Remove local typing ignores in SDK/decorator/script plumbing and bump the
  package and extension version to 0.15.3.
2026-06-15 01:23:57 +02:00

238 lines
7.0 KiB
Python

"""Async browser-cli Python SDK.
The async SDK intentionally reuses the synchronous SDK namespaces instead of
copying every command method. Each async namespace is a thin adapter that runs
the corresponding sync SDK method in a worker thread, while a private command
sender dispatches commands through ``send_command_async``. That keeps command
strings, argument shapes, result mapping, and bound model creation in one place:
``browser_cli.sdk``.
"""
from __future__ import annotations
import asyncio
import functools
from collections.abc import Callable
from typing import TypeVar, cast
from browser_cli.models import Group, Tab
from browser_cli.sdk import NAMESPACE_NAMES
from browser_cli.sdk.workflow_decorators import WorkflowDecoratorsMixin, _NO_INJECT
F = TypeVar("F", bound=Callable)
class AsyncNamespaceAdapter:
"""Async wrapper around one synchronous SDK namespace."""
def __init__(self, sync_namespace):
self._sync = sync_namespace
def __getattr__(self, name: str):
value = getattr(self._sync, name)
if not callable(value):
return value
@functools.wraps(value)
async def wrapper(*args, **kwargs):
return await asyncio.to_thread(value, *args, **kwargs)
return wrapper
class AsyncDecoratorsNS(WorkflowDecoratorsMixin):
"""Async workflow decorators for :class:`AsyncBrowserCLI`.
The public decorator methods are inherited from ``WorkflowDecoratorsMixin``;
only the execution strategy differs: every wrapper is async and awaits both
browser calls and async user functions.
"""
def __init__(self, client: "AsyncBrowserCLI"):
self._c = client
@staticmethod
async def _maybe_await(value):
if hasattr(value, "__await__"):
return await value
return value
def _value_decorator(
self,
func: F | None,
get_value: Callable,
*,
keyword: str | None | object = "tab",
cleanup: Callable | None = None,
):
def decorator(fn: F) -> F:
@functools.wraps(fn)
async def wrapper(*args, **kwargs):
value = await get_value()
try:
extra_args = ()
if keyword is not _NO_INJECT:
extra_args, kwargs = self._inject(kwargs, keyword, value)
return await self._maybe_await(fn(*extra_args, *args, **kwargs))
finally:
if cleanup is not None:
await self._maybe_await(cleanup(value))
return cast(F, wrapper)
return decorator(func) if func is not None else decorator
def new_tab(
self,
url: str,
*,
wait: bool = False,
timeout: float = 30.0,
background: bool = False,
focus: bool = False,
window: str | None = None,
group: str | None = None,
close: bool = False,
keyword: str | None = "tab",
):
def open_tab():
return self._c.tabs.open(
url,
wait=wait,
timeout=timeout,
background=background,
focus=focus,
window=window,
group=group,
)
async def close_tab(tab):
await self._c.tabs.close(tab.id)
return self._value_decorator(None, open_tab, keyword=keyword, cleanup=close_tab if close else None)
def performance_profile(self, profile: str, *, restore: bool = True):
def decorator(fn: F) -> F:
@functools.wraps(fn)
async def wrapper(*args, **kwargs):
previous = (await self._c.perf.status()).get("performanceProfile") if restore else None
await self._c.perf.set_profile(profile)
try:
return await self._maybe_await(fn(*args, **kwargs))
finally:
if previous:
await self._c.perf.set_profile(previous)
return cast(F, wrapper)
return decorator
def retry(
self,
*,
times: int = 3,
delay: float = 0.0,
exceptions: tuple[type[BaseException], ...] = (Exception,),
):
attempts = max(1, times)
def decorator(fn: F) -> F:
@functools.wraps(fn)
async def wrapper(*args, **kwargs):
last_error = None
for attempt in range(attempts):
try:
return await self._maybe_await(fn(*args, **kwargs))
except exceptions as exc:
last_error = exc
if attempt == attempts - 1:
raise
if delay > 0:
await asyncio.sleep(delay)
raise cast(BaseException, last_error)
return cast(F, wrapper)
return decorator
class AsyncBrowserCLI:
"""Async client for a running browser.
Namespace methods are awaitable mirrors of :class:`browser_cli.BrowserCLI`.
"""
_NAMESPACES = NAMESPACE_NAMES
def __init__(self, browser: str | None = None, remote: str | None = None, key: str | None = None):
from browser_cli import BrowserCLI
self._browser = browser
self._remote = remote
self._key = key if key else None
self._sync = BrowserCLI(browser=browser, remote=remote, key=key, _command_sender=self._blocking_async_cmd)
for name in self._NAMESPACES:
setattr(self, name, AsyncNamespaceAdapter(getattr(self._sync, name)))
self.decorators = AsyncDecoratorsNS(self)
@property
def browser(self) -> str | None:
return self._browser
@property
def remote(self) -> str | None:
return self._remote
@property
def key(self) -> str | None:
return self._key
def _blocking_async_cmd(
self,
command: str,
args: dict | None = None,
*,
profile: str | None = None,
remote: str | None = None,
key: str | None = None,
):
"""Run the native async transport from a worker thread.
Async namespace methods execute sync SDK logic in ``asyncio.to_thread``.
Inside that worker thread, the sync SDK's injected command sender lands
here and uses the async transport implementation without blocking the
caller's event loop.
"""
return asyncio.run(self._cmd(command, args, profile=profile, remote=remote, key=key))
async def _cmd(
self,
command: str,
args: dict | None = None,
*,
profile: str | None = None,
remote: str | None = None,
key: str | None = None,
):
from browser_cli.client import send_command_async
return await send_command_async(
command,
args,
profile=self._browser if profile is None else profile,
remote=self._remote if remote is None else remote,
key=self._key if key is None else key,
)
async def command(self, command: str, args: dict | None = None):
return await self._cmd(command, args or {})
async def clients(self) -> list[dict]:
return await self._cmd("clients.list", {})
def tab_from(self, data: dict, *, browser_profile: str | None = None, browser_name: str | None = None, browser_remote: str | None = None) -> Tab:
return self._sync.tab_from(
data,
browser_profile=browser_profile,
browser_name=browser_name,
browser_remote=browser_remote,
)
def group_from(self, data: dict, *, browser_profile: str | None = None, browser_name: str | None = None, browser_remote: str | None = None) -> Group:
return self._sync.group_from(
data,
browser_profile=browser_profile,
browser_name=browser_name,
browser_remote=browser_remote,
)