From 572ee61459e16284d211c167f252de69977786cf Mon Sep 17 00:00:00 2001 From: Zizheng Tai Date: Tue, 21 Apr 2026 17:24:23 -0700 Subject: [PATCH 1/2] Add cloud browser support to narada-pyodide --- packages/narada-core/pyproject.toml | 2 +- packages/narada-pyodide/pyproject.toml | 6 +- .../narada-pyodide/src/narada/__init__.py | 13 +- packages/narada-pyodide/src/narada/client.py | 42 +++ packages/narada-pyodide/src/narada/window.py | 249 ++++++++++++++++-- .../tests/test_cloud_browser.py | 225 ++++++++++++++++ packages/narada/pyproject.toml | 4 +- uv.lock | 16 +- 8 files changed, 520 insertions(+), 37 deletions(-) create mode 100644 packages/narada-pyodide/tests/test_cloud_browser.py diff --git a/packages/narada-core/pyproject.toml b/packages/narada-core/pyproject.toml index be162df..e47b7c8 100644 --- a/packages/narada-core/pyproject.toml +++ b/packages/narada-core/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "narada-core" -version = "0.0.17" +version = "0.0.18" description = "Code shared by the `narada` and `narada-pyodide` packages." license = "Apache-2.0" readme = "README.md" diff --git a/packages/narada-pyodide/pyproject.toml b/packages/narada-pyodide/pyproject.toml index 655d588..185a567 100644 --- a/packages/narada-pyodide/pyproject.toml +++ b/packages/narada-pyodide/pyproject.toml @@ -1,14 +1,14 @@ [project] name = "narada-pyodide" -version = "0.0.43" +version = "0.0.44" description = "Pyodide-compatible Python client SDK for Narada" license = "Apache-2.0" readme = "README.md" authors = [{ name = "Narada", email = "support@narada.ai" }] requires-python = ">=3.12" dependencies = [ - "narada-core==0.0.17", + "narada-core==0.0.18", # Must be a supported version in https://pyodide.org/en/stable/usage/packages-in-pyodide.html "packaging==24.2", ] @@ -23,7 +23,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [dependency-groups] -dev = ["pyodide-py>=0.27.7"] +dev = ["pyodide-py>=0.27.7", "pytest>=8.4.1", "pytest-asyncio>=1.0.0"] [tool.hatch.build.targets.wheel] packages = ["src/narada"] diff --git a/packages/narada-pyodide/src/narada/__init__.py b/packages/narada-pyodide/src/narada/__init__.py index e2144c1..544d452 100644 --- a/packages/narada-pyodide/src/narada/__init__.py +++ b/packages/narada-pyodide/src/narada/__init__.py @@ -1,19 +1,22 @@ +from narada_core.errors import ( + NaradaError, + NaradaTimeoutError, +) +from narada_core.models import Agent, File, Response, ResponseContent + from narada.client import Narada from narada.utils import download_file, render_html from narada.version import __version__ from narada.window import ( + CloudBrowserWindow, LocalBrowserWindow, RemoteBrowserWindow, ) -from narada_core.errors import ( - NaradaError, - NaradaTimeoutError, -) -from narada_core.models import Agent, File, Response, ResponseContent __all__ = [ "__version__", "Agent", + "CloudBrowserWindow", "download_file", "File", "LocalBrowserWindow", diff --git a/packages/narada-pyodide/src/narada/client.py b/packages/narada-pyodide/src/narada/client.py index 34013a4..d821cd8 100644 --- a/packages/narada-pyodide/src/narada/client.py +++ b/packages/narada-pyodide/src/narada/client.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import logging import os from typing import Any @@ -9,6 +10,7 @@ from pyodide.http import pyfetch from narada.version import __version__ +from narada.window import CloudBrowserWindow class Narada: @@ -52,3 +54,43 @@ async def _validate_sdk_config(self) -> None: f"narada-pyodide<={__version__} is not supported. Please reload the page to " f"upgrade to version {package_config.min_required_version} or higher." ) + + async def open_and_initialize_cloud_browser_window( + self, + *, + session_name: str | None = None, + session_timeout: int | None = None, + require_extension: bool = True, + ) -> CloudBrowserWindow: + base_url = os.getenv("NARADA_API_BASE_URL", "https://api.narada.ai/fast/v2") + endpoint_url = ( + f"{base_url}/cloud-browser/create-and-initialize-cloud-browser-session" + ) + request_body = { + "session_name": session_name, + "session_timeout": session_timeout, + "require_extension": require_extension, + } + + resp = await pyfetch( + endpoint_url, + method="POST", + headers={ + "Content-Type": "application/json", + "x-api-key": self._api_key, + }, + body=json.dumps(request_body), + ) + if not resp.ok: + raise RuntimeError( + "Failed to create and initialize cloud browser session: " + f"{resp.status} {await resp.text()}\n" + f"Endpoint URL: {endpoint_url}" + ) + + response_data = await resp.json() + return CloudBrowserWindow( + browser_window_id=response_data["browser_window_id"], + session_id=response_data["session_id"], + api_key=self._api_key, + ) diff --git a/packages/narada-pyodide/src/narada/window.py b/packages/narada-pyodide/src/narada/window.py index 7a80c4a..b9ab730 100644 --- a/packages/narada-pyodide/src/narada/window.py +++ b/packages/narada-pyodide/src/narada/window.py @@ -1,10 +1,13 @@ import asyncio import json +import logging import os import time from abc import ABC +from dataclasses import dataclass from http import HTTPStatus from typing import IO, TYPE_CHECKING, Any, Literal, Optional, TypeVar, cast, overload +from urllib.parse import urlencode from js import AbortController, setTimeout # type: ignore from narada_core.actions.models import ( @@ -61,6 +64,8 @@ # Magic variable injected by the JavaScript harness that stores the IDs of the current runnables # in the stack on the frontend. +logger = logging.getLogger(__name__) + _cached_parent_run_ids: list[str] | None = None @@ -86,6 +91,15 @@ async def _narada_get_id_token() -> str: ... _ResponseModel = TypeVar("_ResponseModel", bound=BaseModel) +@dataclass +class SessionDownloadItem: + """A file downloaded during a cloud browser session (file name, size, presigned GET URL).""" + + file_name: str + size: int + download_url: str + + class BaseBrowserWindow(ABC): _api_key: str | None _base_url: str @@ -117,6 +131,25 @@ def __init__( def browser_window_id(self) -> str: return self._browser_window_id + async def _get_auth_headers( + self, *, include_content_type: bool = False + ) -> dict[str, str]: + headers: dict[str, str] = {} + if include_content_type: + headers["Content-Type"] = "application/json" + + if self._api_key is not None: + headers["x-api-key"] = self._api_key + return headers + + assert self._user_id is not None + assert self._env is not None + + headers["Authorization"] = f"Bearer {await _narada_get_id_token()}" + headers["X-Narada-User-ID"] = self._user_id + headers["X-Narada-Env"] = self._env + return headers + async def upload_file(self, *, file: IO) -> File: """Uploads a file that can be used as an attachment in a subsequent `agent` request. @@ -200,16 +233,7 @@ async def dispatch_request( """ deadline = time.monotonic() + timeout - headers = {"Content-Type": "application/json"} - if self._api_key is not None: - headers["x-api-key"] = self._api_key - else: - assert self._user_id is not None - assert self._env is not None - - headers["Authorization"] = f"Bearer {await _narada_get_id_token()}" - headers["X-Narada-User-ID"] = self._user_id - headers["X-Narada-Env"] = self._env + headers = await self._get_auth_headers(include_content_type=True) agent_prefix = ( agent.prompt_prefix() if isinstance(agent, Agent) else f"{agent} " @@ -605,16 +629,7 @@ async def _run_extension_action( *, timeout: int | None = None, ) -> _ResponseModel | None: - headers = {"Content-Type": "application/json"} - if self._api_key is not None: - headers["x-api-key"] = self._api_key - else: - assert self._user_id is not None - assert self._env is not None - - headers["Authorization"] = f"Bearer {await _narada_get_id_token()}" - headers["X-Narada-User-ID"] = self._user_id - headers["X-Narada-Env"] = self._env + headers = await self._get_auth_headers(include_content_type=True) body = { "action": request.model_dump(), @@ -673,7 +688,13 @@ def __str__(self) -> str: class RemoteBrowserWindow(BaseBrowserWindow): - def __init__(self, *, browser_window_id: str, api_key: str | None = None) -> None: + def __init__( + self, + *, + browser_window_id: str, + cloud_browser_session_id: str | None = None, + api_key: str | None = None, + ) -> None: super().__init__( api_key=api_key or os.environ["NARADA_API_KEY"], base_url=os.getenv("NARADA_API_BASE_URL", "https://api.narada.ai/fast/v2"), @@ -681,6 +702,192 @@ def __init__(self, *, browser_window_id: str, api_key: str | None = None) -> Non env=None, browser_window_id=browser_window_id, ) + self._cloud_browser_session_id = cloud_browser_session_id + + @property + def cloud_browser_session_id(self) -> str | None: + return self._cloud_browser_session_id + + async def close(self, *, timeout: int | None = None) -> None: + """Closes the browser window or stops the backing cloud session.""" + if self._cloud_browser_session_id is None: + return await super().close(timeout=timeout) + + await _stop_cloud_browser_session( + base_url=self._base_url, + auth_headers=await self._get_auth_headers(), + session_id=self._cloud_browser_session_id, + timeout=timeout, + ) + + async def get_downloaded_files(self) -> list[SessionDownloadItem]: + """Return files downloaded during this cloud browser session (file name, size, presigned GET URL per file).""" + if self._cloud_browser_session_id is None: + raise ValueError( + "Cloud browser session ID is required to get downloaded files" + ) + + return await _get_cloud_browser_downloads( + base_url=self._base_url, + auth_headers=await self._get_auth_headers(), + session_id=self._cloud_browser_session_id, + ) def __str__(self) -> str: return f"RemoteBrowserWindow(browser_window_id={self.browser_window_id})" + + +class CloudBrowserWindow(BaseBrowserWindow): + def __init__( + self, + *, + browser_window_id: str, + session_id: str, + api_key: str | None = None, + ) -> None: + super().__init__( + api_key=api_key or os.environ["NARADA_API_KEY"], + base_url=os.getenv("NARADA_API_BASE_URL", "https://api.narada.ai/fast/v2"), + user_id=None, + env=None, + browser_window_id=browser_window_id, + ) + self._session_id = session_id + + @property + def cloud_browser_session_id(self) -> str: + return self._session_id + + async def close(self, *, timeout: int | None = None) -> None: + """Stops the cloud browser session.""" + await _stop_cloud_browser_session( + base_url=self._base_url, + auth_headers=await self._get_auth_headers(), + session_id=self._session_id, + timeout=timeout, + ) + + async def get_downloaded_files(self) -> list[SessionDownloadItem]: + """Return files downloaded during this cloud browser session (file name, size, presigned GET URL per file).""" + return await _get_cloud_browser_downloads( + base_url=self._base_url, + auth_headers=await self._get_auth_headers(), + session_id=self._session_id, + ) + + def __str__(self) -> str: + return ( + "CloudBrowserWindow(" + f"cloud_browser_session_id={self._session_id}, " + f"browser_window_id={self.browser_window_id}" + ")" + ) + + +def _build_cloud_browser_url( + base_url: str, path: str, *, params: dict[str, str] | None = None +) -> str: + if not params: + return f"{base_url}{path}" + return f"{base_url}{path}?{urlencode(params)}" + + +async def _fetch_presigned_download_url( + *, + base_url: str, + auth_headers: dict[str, str], + session_id: str, + key: str, +) -> str: + fetch_response = await pyfetch( + _build_cloud_browser_url( + base_url, + "/cloud-browser/replay/download-url", + params={"session_id": session_id, "key": key}, + ), + headers=auth_headers, + ) + if not fetch_response.ok: + raise NaradaError( + "Failed to fetch cloud browser download URL: " + f"{fetch_response.status} {await fetch_response.text()}" + ) + data = await fetch_response.json() + return data["presigned_url"] + + +async def _get_cloud_browser_downloads( + *, + base_url: str, + auth_headers: dict[str, str], + session_id: str, +) -> list[SessionDownloadItem]: + fetch_response = await pyfetch( + _build_cloud_browser_url( + base_url, + "/cloud-browser/replay/downloads", + params={"session_id": session_id}, + ), + headers=auth_headers, + ) + if not fetch_response.ok: + raise NaradaError( + "Failed to fetch cloud browser downloads: " + f"{fetch_response.status} {await fetch_response.text()}" + ) + + data = await fetch_response.json() + files = data.get("downloaded_files") or [] + if not files: + return [] + + presigned_urls = await asyncio.gather( + *[ + _fetch_presigned_download_url( + base_url=base_url, + auth_headers=auth_headers, + session_id=session_id, + key=item["key"], + ) + for item in files + ] + ) + return [ + SessionDownloadItem( + file_name=item["file_name"], + size=item["size"], + download_url=presigned_urls[index], + ) + for index, item in enumerate(files) + ] + + +async def _stop_cloud_browser_session( + *, + base_url: str, + auth_headers: dict[str, str], + session_id: str, + timeout: int | None = None, +) -> None: + try: + fetch_response = await pyfetch( + f"{base_url}/cloud-browser/stop-cloud-browser-session", + method="POST", + headers={**auth_headers, "Content-Type": "application/json"}, + body=json.dumps({"session_id": session_id}), + ) + if not fetch_response.ok: + logger.warning( + "Failed to stop session %s: %s", session_id, fetch_response.status + ) + return + + response_data = await fetch_response.json() + if not response_data.get("success"): + logger.warning( + "Failed to stop session %s: %s", + session_id, + response_data.get("message"), + ) + except Exception as e: + logger.warning("Error calling stop session endpoint: %s", e) diff --git a/packages/narada-pyodide/tests/test_cloud_browser.py b/packages/narada-pyodide/tests/test_cloud_browser.py new file mode 100644 index 0000000..922222d --- /dev/null +++ b/packages/narada-pyodide/tests/test_cloud_browser.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +import json +import sys +from pathlib import Path +from types import ModuleType, SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +PROJECT_ROOT = Path("/Users/zizheng/Projects/narada-python-sdk") +PYODIDE_SRC = PROJECT_ROOT / "packages" / "narada-pyodide" / "src" +CORE_SRC = PROJECT_ROOT / "packages" / "narada-core" / "src" + + +class _FakeResponse: + def __init__( + self, + *, + ok: bool = True, + status: int = 200, + json_data: object | None = None, + text_data: str = "", + ) -> None: + self.ok = ok + self.status = status + self._json_data = json_data + self._text_data = text_data + + async def json(self) -> object | None: + return self._json_data + + async def text(self) -> str: + return self._text_data + + +class _FakeJsProxy: + def __init__(self, value: object): + self._value = value + + def to_py(self) -> object: + return self._value + + +def _clear_modules() -> None: + for name in list(sys.modules): + if name == "narada" or name.startswith("narada."): + sys.modules.pop(name, None) + for name in ("js", "pyodide", "pyodide.http", "pyodide.ffi"): + sys.modules.pop(name, None) + + +def _import_pyodide_narada(monkeypatch: pytest.MonkeyPatch, *, pyfetch: AsyncMock): + _clear_modules() + monkeypatch.syspath_prepend(str(CORE_SRC)) + monkeypatch.syspath_prepend(str(PYODIDE_SRC)) + + js_module = ModuleType("js") + + class _AbortController: + @staticmethod + def new() -> SimpleNamespace: + return SimpleNamespace(signal=object(), abort=lambda: None) + + js_module.AbortController = _AbortController + js_module.setTimeout = lambda callback, timeout: None + + pyodide_module = ModuleType("pyodide") + pyodide_module.__path__ = [] + pyodide_http_module = ModuleType("pyodide.http") + pyodide_http_module.pyfetch = pyfetch + pyodide_ffi_module = ModuleType("pyodide.ffi") + pyodide_ffi_module.JsProxy = _FakeJsProxy + pyodide_ffi_module.create_once_callable = lambda fn: fn + + monkeypatch.setitem(sys.modules, "js", js_module) + monkeypatch.setitem(sys.modules, "pyodide", pyodide_module) + monkeypatch.setitem(sys.modules, "pyodide.http", pyodide_http_module) + monkeypatch.setitem(sys.modules, "pyodide.ffi", pyodide_ffi_module) + + import importlib + + narada_pkg = importlib.import_module("narada") + client_module = importlib.import_module("narada.client") + window_module = importlib.import_module("narada.window") + window_module._narada_parent_run_ids = _FakeJsProxy([]) + return narada_pkg, client_module, window_module + + +@pytest.mark.asyncio +async def test_open_and_initialize_cloud_browser_window_maps_response( + monkeypatch: pytest.MonkeyPatch, +) -> None: + pyfetch = AsyncMock( + return_value=_FakeResponse( + json_data={ + "session_id": "session-123", + "session_name": "demo", + "browser_window_id": "browser-window-123", + } + ) + ) + narada_pkg, _, _ = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch) + + client = narada_pkg.Narada(api_key="test-api-key") + window = await client.open_and_initialize_cloud_browser_window( + session_name="demo", + session_timeout=321, + require_extension=False, + ) + + assert isinstance(window, narada_pkg.CloudBrowserWindow) + assert window.browser_window_id == "browser-window-123" + assert window.cloud_browser_session_id == "session-123" + + call = pyfetch.await_args + assert call is not None + assert call.args[0].endswith( + "/cloud-browser/create-and-initialize-cloud-browser-session" + ) + assert call.kwargs["method"] == "POST" + assert call.kwargs["headers"] == { + "Content-Type": "application/json", + "x-api-key": "test-api-key", + } + assert json.loads(call.kwargs["body"]) == { + "session_name": "demo", + "session_timeout": 321, + "require_extension": False, + } + + +@pytest.mark.asyncio +async def test_cloud_browser_window_close_stops_cloud_session( + monkeypatch: pytest.MonkeyPatch, +) -> None: + pyfetch = AsyncMock(return_value=_FakeResponse(json_data={"success": True})) + _, _, window_module = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch) + + window = window_module.CloudBrowserWindow( + browser_window_id="browser-window-123", + session_id="session-123", + api_key="test-api-key", + ) + await window.close() + + call = pyfetch.await_args + assert call is not None + assert call.args[0].endswith("/cloud-browser/stop-cloud-browser-session") + assert call.kwargs["method"] == "POST" + assert call.kwargs["headers"] == { + "x-api-key": "test-api-key", + "Content-Type": "application/json", + } + assert json.loads(call.kwargs["body"]) == {"session_id": "session-123"} + + +@pytest.mark.asyncio +async def test_cloud_browser_window_get_downloaded_files_returns_presigned_urls( + monkeypatch: pytest.MonkeyPatch, +) -> None: + pyfetch = AsyncMock( + side_effect=[ + _FakeResponse( + json_data={ + "downloaded_files": [ + { + "file_name": "report.pdf", + "key": "downloads/session-123/report.pdf", + "size": 42, + } + ] + } + ), + _FakeResponse(json_data={"presigned_url": "https://example.com/report.pdf"}), + ] + ) + _, _, window_module = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch) + + window = window_module.CloudBrowserWindow( + browser_window_id="browser-window-123", + session_id="session-123", + api_key="test-api-key", + ) + files = await window.get_downloaded_files() + + assert files == [ + window_module.SessionDownloadItem( + file_name="report.pdf", + size=42, + download_url="https://example.com/report.pdf", + ) + ] + assert pyfetch.await_count == 2 + first_call, second_call = pyfetch.await_args_list + assert "session_id=session-123" in first_call.args[0] + assert first_call.args[0].endswith( + "/cloud-browser/replay/downloads?session_id=session-123" + ) + assert "session_id=session-123" in second_call.args[0] + assert "key=downloads%2Fsession-123%2Freport.pdf" in second_call.args[0] + + +@pytest.mark.asyncio +async def test_remote_browser_window_without_cloud_session_keeps_extension_action_close( + monkeypatch: pytest.MonkeyPatch, +) -> None: + pyfetch = AsyncMock( + return_value=_FakeResponse(json_data={"status": "success", "data": None}) + ) + _, _, window_module = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch) + + window = window_module.RemoteBrowserWindow( + browser_window_id="browser-window-123", + api_key="test-api-key", + ) + await window.close() + + call = pyfetch.await_args + assert call is not None + assert call.args[0].endswith("/extension-actions") + assert call.kwargs["method"] == "POST" + payload = json.loads(call.kwargs["body"]) + assert payload["browserWindowId"] == "browser-window-123" + assert payload["action"]["name"] == "close_window" diff --git a/packages/narada/pyproject.toml b/packages/narada/pyproject.toml index cab7376..59e1179 100644 --- a/packages/narada/pyproject.toml +++ b/packages/narada/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "narada" -version = "0.1.42" +version = "0.1.43" description = "Python client SDK for Narada" license = "Apache-2.0" readme = "README.md" authors = [{ name = "Narada", email = "support@narada.ai" }] requires-python = ">=3.12" dependencies = [ - "narada-core==0.0.17", + "narada-core==0.0.18", "aiohttp>=3.12.13", "playwright>=1.53.0", "rich>=14.0.0", diff --git a/uv.lock b/uv.lock index 5fd8861..66d4fea 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" resolution-markers = [ "python_full_version >= '3.13'", @@ -312,7 +312,7 @@ wheels = [ [[package]] name = "narada" -version = "0.1.42" +version = "0.1.43" source = { editable = "packages/narada" } dependencies = [ { name = "aiohttp" }, @@ -345,7 +345,7 @@ dev = [ [[package]] name = "narada-core" -version = "0.0.17" +version = "0.0.18" source = { editable = "packages/narada-core" } dependencies = [ { name = "pydantic" }, @@ -356,7 +356,7 @@ requires-dist = [{ name = "pydantic", specifier = "==2.12.5" }] [[package]] name = "narada-pyodide" -version = "0.0.43" +version = "0.0.44" source = { editable = "packages/narada-pyodide" } dependencies = [ { name = "narada-core" }, @@ -367,6 +367,8 @@ dependencies = [ dev = [ { name = "pyodide-py", version = "0.27.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, { name = "pyodide-py", version = "0.28.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, ] [package.metadata] @@ -376,7 +378,11 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "pyodide-py", specifier = ">=0.27.7" }] +dev = [ + { name = "pyodide-py", specifier = ">=0.27.7" }, + { name = "pytest", specifier = ">=8.4.1" }, + { name = "pytest-asyncio", specifier = ">=1.0.0" }, +] [[package]] name = "packaging" From 572da661db57eb3322fe8eb6220a2d9464b50de0 Mon Sep 17 00:00:00 2001 From: Zizheng Tai Date: Tue, 21 Apr 2026 17:25:42 -0700 Subject: [PATCH 2/2] Format --- packages/narada-pyodide/tests/test_cloud_browser.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/narada-pyodide/tests/test_cloud_browser.py b/packages/narada-pyodide/tests/test_cloud_browser.py index 922222d..4420efa 100644 --- a/packages/narada-pyodide/tests/test_cloud_browser.py +++ b/packages/narada-pyodide/tests/test_cloud_browser.py @@ -172,7 +172,9 @@ async def test_cloud_browser_window_get_downloaded_files_returns_presigned_urls( ] } ), - _FakeResponse(json_data={"presigned_url": "https://example.com/report.pdf"}), + _FakeResponse( + json_data={"presigned_url": "https://example.com/report.pdf"} + ), ] ) _, _, window_module = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch)