Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/narada-pyodide/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@

[project]
name = "narada-pyodide"
version = "0.0.44"
version = "0.0.45a1"
description = "Pyodide-compatible Python client SDK for Narada"
license = "Apache-2.0"
readme = "README.md"
Expand Down
30 changes: 23 additions & 7 deletions packages/narada-pyodide/src/narada/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,19 @@
from pyodide.http import pyfetch

from narada.version import __version__
from narada.window import CloudBrowserWindow
from narada.window import CloudBrowserWindow, _build_auth_headers, _normalize_narada_env


class Narada:
def __init__(self, *, api_key: str | None = None) -> None:
self._api_key = api_key or os.environ["NARADA_API_KEY"]
self._api_key = api_key or os.environ.get("NARADA_API_KEY")
self._user_id = os.environ.get("NARADA_USER_ID")
self._env = _normalize_narada_env(os.environ.get("NARADA_ENV"))

if self._api_key is None and (self._user_id is None or self._env is None):
raise ValueError(
"Either `api_key` or all of `NARADA_USER_ID` and `NARADA_ENV` must be provided"
)

async def __aenter__(self) -> Narada:
await self._validate_sdk_config()
Expand All @@ -27,9 +34,14 @@ async def __aexit__(self, *args: Any) -> None:
async def _fetch_sdk_config(self) -> _SdkConfig | None:
base_url = os.getenv("NARADA_API_BASE_URL", "https://api.narada.ai/fast/v2")
url = f"{base_url}/sdk/config"
headers = await _build_auth_headers(
api_key=self._api_key,
user_id=self._user_id,
env=self._env,
)

try:
resp = await pyfetch(url, headers={"x-api-key": self._api_key})
resp = await pyfetch(url, headers=headers)
if not resp.ok:
logging.warning(
"Failed to fetch SDK config: %s %s", resp.status, await resp.text()
Expand Down Expand Up @@ -66,6 +78,11 @@ async def open_and_initialize_cloud_browser_window(
endpoint_url = (
f"{base_url}/cloud-browser/create-and-initialize-cloud-browser-session"
)
headers = await _build_auth_headers(
api_key=self._api_key,
user_id=self._user_id,
env=self._env,
)
request_body = {
"session_name": session_name,
"session_timeout": session_timeout,
Expand All @@ -75,10 +92,7 @@ async def open_and_initialize_cloud_browser_window(
resp = await pyfetch(
endpoint_url,
method="POST",
headers={
"Content-Type": "application/json",
"x-api-key": self._api_key,
},
headers=headers,
body=json.dumps(request_body),
)
if not resp.ok:
Expand All @@ -93,4 +107,6 @@ async def open_and_initialize_cloud_browser_window(
browser_window_id=response_data["browser_window_id"],
session_id=response_data["session_id"],
api_key=self._api_key,
user_id=self._user_id,
env=self._env,
)
4 changes: 2 additions & 2 deletions packages/narada-pyodide/src/narada/version.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import importlib.metadata

try:
__version__ = importlib.metadata.version("narada")
__version__ = importlib.metadata.version("narada-pyodide")
except Exception:
# Fallback version if package metadata is not available
# Fallback sentinel. Validation treats this as a fatal release issue.
__version__ = "unknown"
79 changes: 48 additions & 31 deletions packages/narada-pyodide/src/narada/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,35 @@ async def _narada_get_id_token() -> str: ...
_ResponseModel = TypeVar("_ResponseModel", bound=BaseModel)


def _normalize_narada_env(env: str | None) -> Literal["prod", "dev", None]:
if env is not None and env not in ("prod", "dev"):
raise ValueError(f"Invalid environment: {env!r}")
return cast(Literal["prod", "dev", None], env)


async def _build_auth_headers(
*,
api_key: str | None,
user_id: str | None,
env: Literal["prod", "dev", None],
) -> dict[str, str]:
headers: dict[str, str] = {"Content-Type": "application/json"}

if api_key is not None:
headers["x-api-key"] = api_key
return headers

if user_id is None or env is None:
raise ValueError(
"Either `api_key` or all of `user_id` and `env` must be provided"
)

headers["Authorization"] = f"Bearer {await _narada_get_id_token()}"
headers["X-Narada-User-ID"] = user_id
headers["X-Narada-Env"] = env
return headers


@dataclass
class SessionDownloadItem:
"""A file downloaded during a cloud browser session (file name, size, presigned GET URL)."""
Expand Down Expand Up @@ -131,24 +160,12 @@ 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 _get_auth_headers(self) -> dict[str, str]:
return await _build_auth_headers(
api_key=self._api_key,
user_id=self._user_id,
env=self._env,
)

async def upload_file(self, *, file: IO) -> File:
"""Uploads a file that can be used as an attachment in a subsequent `agent` request.
Expand Down Expand Up @@ -233,7 +250,7 @@ async def dispatch_request(
"""
deadline = time.monotonic() + timeout

headers = await self._get_auth_headers(include_content_type=True)
headers = await self._get_auth_headers()

agent_prefix = (
agent.prompt_prefix() if isinstance(agent, Agent) else f"{agent} "
Expand Down Expand Up @@ -629,7 +646,7 @@ async def _run_extension_action(
*,
timeout: int | None = None,
) -> _ResponseModel | None:
headers = await self._get_auth_headers(include_content_type=True)
headers = await self._get_auth_headers()

body = {
"action": request.model_dump(),
Expand Down Expand Up @@ -671,15 +688,11 @@ async def _run_extension_action(

class LocalBrowserWindow(BaseBrowserWindow):
def __init__(self) -> None:
env = os.environ.get("NARADA_ENV")
if env is not None and env not in ("prod", "dev"):
raise ValueError(f"Invalid environment: {env!r}")

super().__init__(
api_key=os.environ.get("NARADA_API_KEY"),
base_url=os.getenv("NARADA_API_BASE_URL", "https://api.narada.ai/fast/v2"),
user_id=os.environ.get("NARADA_USER_ID"),
env=env,
env=_normalize_narada_env(os.environ.get("NARADA_ENV")),
browser_window_id=os.environ["NARADA_BROWSER_WINDOW_ID"],
)

Expand All @@ -694,12 +707,14 @@ def __init__(
browser_window_id: str,
cloud_browser_session_id: str | None = None,
api_key: str | None = None,
user_id: str | None = None,
env: Literal["prod", "dev", None] = None,
) -> None:
super().__init__(
api_key=api_key or os.environ["NARADA_API_KEY"],
api_key=api_key or os.environ.get("NARADA_API_KEY"),
base_url=os.getenv("NARADA_API_BASE_URL", "https://api.narada.ai/fast/v2"),
user_id=None,
env=None,
user_id=user_id or os.environ.get("NARADA_USER_ID"),
env=_normalize_narada_env(env or os.environ.get("NARADA_ENV")),
browser_window_id=browser_window_id,
)
self._cloud_browser_session_id = cloud_browser_session_id
Expand Down Expand Up @@ -744,12 +759,14 @@ def __init__(
browser_window_id: str,
session_id: str,
api_key: str | None = None,
user_id: str | None = None,
env: Literal["prod", "dev", None] = None,
) -> None:
super().__init__(
api_key=api_key or os.environ["NARADA_API_KEY"],
api_key=api_key or os.environ.get("NARADA_API_KEY"),
base_url=os.getenv("NARADA_API_BASE_URL", "https://api.narada.ai/fast/v2"),
user_id=None,
env=None,
user_id=user_id or os.environ.get("NARADA_USER_ID"),
env=_normalize_narada_env(env or os.environ.get("NARADA_ENV")),
browser_window_id=browser_window_id,
)
self._session_id = session_id
Expand Down
93 changes: 93 additions & 0 deletions packages/narada-pyodide/tests/test_cloud_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ def new() -> SimpleNamespace:
client_module = importlib.import_module("narada.client")
window_module = importlib.import_module("narada.window")
window_module._narada_parent_run_ids = _FakeJsProxy([])
window_module._narada_get_id_token = AsyncMock(return_value="frontend-id-token")
return narada_pkg, client_module, window_module


Expand Down Expand Up @@ -130,6 +131,98 @@ async def test_open_and_initialize_cloud_browser_window_maps_response(
}


@pytest.mark.asyncio
async def test_open_and_initialize_cloud_browser_window_supports_frontend_bearer_auth(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("NARADA_API_KEY", raising=False)
monkeypatch.setenv("NARADA_USER_ID", "user-123")
monkeypatch.setenv("NARADA_ENV", "dev")

pyfetch = AsyncMock(
side_effect=[
_FakeResponse(
json_data={
"packages": {"narada-pyodide": {"min_required_version": "0.0.1"}}
}
),
_FakeResponse(
json_data={
"session_id": "session-456",
"session_name": "demo",
"browser_window_id": "browser-window-456",
}
),
_FakeResponse(json_data={"success": True}),
]
)
narada_pkg, _, _ = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch)

async with narada_pkg.Narada() as client:
window = await client.open_and_initialize_cloud_browser_window(
session_name="demo",
session_timeout=321,
require_extension=True,
)

assert isinstance(window, narada_pkg.CloudBrowserWindow)
assert window.browser_window_id == "browser-window-456"
assert window.cloud_browser_session_id == "session-456"

sdk_config_call, create_call = pyfetch.await_args_list
assert sdk_config_call.args[0].endswith("/sdk/config")
assert sdk_config_call.kwargs["headers"] == {
"Content-Type": "application/json",
"Authorization": "Bearer frontend-id-token",
"X-Narada-User-ID": "user-123",
"X-Narada-Env": "dev",
}
assert create_call.args[0].endswith(
"/cloud-browser/create-and-initialize-cloud-browser-session"
)
assert create_call.kwargs["headers"] == {
"Content-Type": "application/json",
"Authorization": "Bearer frontend-id-token",
"X-Narada-User-ID": "user-123",
"X-Narada-Env": "dev",
}

await window.close()
stop_call = pyfetch.await_args_list[-1]
assert stop_call.args[0].endswith("/cloud-browser/stop-cloud-browser-session")
assert stop_call.kwargs["headers"] == {
"Content-Type": "application/json",
"Authorization": "Bearer frontend-id-token",
"X-Narada-User-ID": "user-123",
"X-Narada-Env": "dev",
}


@pytest.mark.asyncio
async def test_open_and_initialize_cloud_browser_window_raises_when_version_is_unknown(
monkeypatch: pytest.MonkeyPatch,
) -> None:
pyfetch = AsyncMock(
return_value=_FakeResponse(
json_data={
"packages": {"narada-pyodide": {"min_required_version": "999.0.0"}}
}
)
)
narada_pkg, client_module, _ = _import_pyodide_narada(monkeypatch, pyfetch=pyfetch)
monkeypatch.setattr(client_module, "__version__", "unknown")

with pytest.raises(RuntimeError) as exc_info:
async with narada_pkg.Narada(api_key="test-api-key"):
pass

assert (
"narada-pyodide version metadata is unavailable or invalid ('unknown')"
in str(exc_info.value)
)
assert pyfetch.await_count == 1


@pytest.mark.asyncio
async def test_cloud_browser_window_close_stops_cloud_session(
monkeypatch: pytest.MonkeyPatch,
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading