"""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 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 wrapper # type: ignore[return-value] 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 wrapper # type: ignore[return-value] 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 last_error # type: ignore[misc] return wrapper # type: ignore[return-value] 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, )