narada 0.1.32__tar.gz → 0.1.33a1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: narada
3
- Version: 0.1.32
3
+ Version: 0.1.33a1
4
4
  Summary: Python client SDK for Narada
5
5
  Project-URL: Homepage, https://github.com/NaradaAI/narada-python-sdk/narada
6
6
  Project-URL: Repository, https://github.com/NaradaAI/narada-python-sdk
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "narada"
3
- version = "0.1.32"
3
+ version = "0.1.33a1"
4
4
  description = "Python client SDK for Narada"
5
5
  license = "Apache-2.0"
6
6
  readme = "README.md"
@@ -2,7 +2,7 @@ from narada.client import Narada
2
2
  from narada.config import BrowserConfig, ProxyConfig
3
3
  from narada.utils import download_file, render_html
4
4
  from narada.version import __version__
5
- from narada.window import LocalBrowserWindow, RemoteBrowserWindow
5
+ from narada.window import CloudBrowserWindow, LocalBrowserWindow, RemoteBrowserWindow
6
6
  from narada_core.errors import (
7
7
  NaradaError,
8
8
  NaradaExtensionMissingError,
@@ -17,6 +17,7 @@ __all__ = [
17
17
  "__version__",
18
18
  "Agent",
19
19
  "BrowserConfig",
20
+ "CloudBrowserWindow",
20
21
  "download_file",
21
22
  "File",
22
23
  "LocalBrowserWindow",
@@ -14,7 +14,11 @@ import semver
14
14
  from narada.config import BrowserConfig, ProxyConfig
15
15
  from narada.utils import assert_never
16
16
  from narada.version import __version__
17
- from narada.window import LocalBrowserWindow, create_side_panel_url
17
+ from narada.window import (
18
+ LocalBrowserWindow,
19
+ CloudBrowserWindow,
20
+ create_side_panel_url,
21
+ )
18
22
  from narada_core.errors import (
19
23
  NaradaExtensionMissingError,
20
24
  NaradaExtensionUnauthenticatedError,
@@ -57,10 +61,12 @@ class Narada:
57
61
  _console: Console
58
62
  _playwright_context_manager: PlaywrightContextManager | None = None
59
63
  _playwright: Playwright | None = None
64
+ _cloud_windows: set[CloudBrowserWindow]
60
65
 
61
66
  def __init__(self, *, api_key: str | None = None) -> None:
62
67
  self._api_key = api_key or os.environ["NARADA_API_KEY"]
63
68
  self._console = Console()
69
+ self._cloud_windows = set()
64
70
 
65
71
  async def __aenter__(self) -> Narada:
66
72
  await self._validate_sdk_config()
@@ -70,6 +76,11 @@ class Narada:
70
76
  return self
71
77
 
72
78
  async def __aexit__(self, *args: Any) -> None:
79
+ async with asyncio.TaskGroup() as tg:
80
+ for cloud_window in self._cloud_windows:
81
+ tg.create_task(cloud_window.cleanup())
82
+ self._cloud_windows.clear()
83
+
73
84
  if self._playwright_context_manager is None:
74
85
  return
75
86
 
@@ -133,6 +144,121 @@ class Narada:
133
144
  context=side_panel_page.context,
134
145
  )
135
146
 
147
+ async def open_and_initialize_cloud_browser_window(
148
+ self,
149
+ config: BrowserConfig | None = None,
150
+ session_name: str | None = None,
151
+ session_timeout: int | None = None,
152
+ ) -> CloudBrowserWindow:
153
+ """Creates a cloud browser by calling the backend.
154
+
155
+ The backend creates a cloud browser session and returns
156
+ a CDP WebSocket URL. This method connects to it, initializes the extension,
157
+ and returns a CloudBrowserWindow instance.
158
+ """
159
+ assert self._playwright is not None
160
+ playwright = self._playwright
161
+
162
+ config = config or BrowserConfig()
163
+ base_url = os.getenv("NARADA_API_BASE_URL", "https://api.narada.ai/fast/v2")
164
+ request_body = {
165
+ "session_name": session_name,
166
+ "session_timeout": session_timeout,
167
+ }
168
+ endpoint_url = f"{base_url}/cloud-browser/create-cloud-browser-session"
169
+
170
+ async with aiohttp.ClientSession() as session:
171
+ async with session.post(
172
+ endpoint_url,
173
+ headers={"x-api-key": self._api_key},
174
+ json=request_body,
175
+ timeout=aiohttp.ClientTimeout(
176
+ total=180
177
+ ), # 3 minutes for session startup
178
+ ) as resp:
179
+ if not resp.ok:
180
+ error_text = await resp.text()
181
+ raise RuntimeError(
182
+ f"Failed to create cloud browser session: {resp.status} {error_text}\n"
183
+ f"Endpoint URL: {endpoint_url}"
184
+ )
185
+ response_data = await resp.json()
186
+
187
+ cdp_websocket_url = response_data["cdp_websocket_url"]
188
+ session_id = response_data["session_id"]
189
+ login_url = response_data["login_url"]
190
+ cdp_auth_headers = response_data.get("cdp_auth_headers")
191
+
192
+ # Connect to browser via CDP with authentication headers
193
+ try:
194
+ browser = await playwright.chromium.connect_over_cdp(
195
+ cdp_websocket_url, headers=cdp_auth_headers
196
+ )
197
+ except Exception:
198
+ # Clean up the session if CDP connection fails
199
+ try:
200
+ async with aiohttp.ClientSession() as cleanup_session:
201
+ async with cleanup_session.post(
202
+ f"{base_url}/cloud-browser/stop-cloud-browser-session",
203
+ headers={"x-api-key": self._api_key},
204
+ json={"session_id": session_id},
205
+ timeout=aiohttp.ClientTimeout(total=10),
206
+ ) as resp:
207
+ if resp.ok:
208
+ logging.info(
209
+ f"Cleaned up session {session_id} after CDP connection failure"
210
+ )
211
+ else:
212
+ logging.warning(
213
+ f"Failed to cleanup session {session_id}: {resp.status}"
214
+ )
215
+ except Exception as cleanup_error:
216
+ logging.warning(
217
+ f"Error cleaning up session {session_id}: {cleanup_error}"
218
+ )
219
+ # Re-raise the original connection error
220
+ raise
221
+ context = (
222
+ browser.contexts[0] if browser.contexts else await browser.new_context()
223
+ )
224
+
225
+ # Navigate to login URL (provided by backend with custom token)
226
+ initialization_page = await context.new_page()
227
+ await initialization_page.goto(
228
+ login_url, wait_until="domcontentloaded", timeout=60_000
229
+ )
230
+
231
+ # Wait for sign-in to process to complete.
232
+ await asyncio.sleep(15) # TODO: improve it in the future
233
+ await initialization_page.reload(wait_until="domcontentloaded", timeout=60_000)
234
+
235
+ # Wait for browser window ID
236
+ browser_window_id = await self._wait_for_browser_window_id(
237
+ initialization_page, config
238
+ )
239
+
240
+ # TODO: consider this
241
+ # Get side panel page
242
+ # side_panel_url = create_side_panel_url(config, browser_window_id)
243
+ # side_panel_page = next(
244
+ # (p for p in context.pages if p.url == side_panel_url), None
245
+ # )
246
+ # await self._fix_download_behavior(side_panel_page)
247
+
248
+ cloud_window = CloudBrowserWindow(
249
+ browser_window_id=browser_window_id,
250
+ session_id=session_id,
251
+ api_key=self._api_key,
252
+ )
253
+
254
+ # Track the window for cleanup in __aexit__
255
+ self._cloud_windows.add(cloud_window)
256
+
257
+ if config.interactive:
258
+ self._print_success_message(browser_window_id)
259
+
260
+ return cloud_window
261
+
136
262
  async def initialize_in_existing_browser_window(
137
263
  self, config: BrowserConfig | None = None
138
264
  ) -> LocalBrowserWindow:
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import logging
2
3
  import os
3
4
  import time
4
5
  from abc import ABC
@@ -47,9 +48,13 @@ from narada_core.models import (
47
48
  Response,
48
49
  UserResourceCredentials,
49
50
  )
50
- from playwright.async_api import BrowserContext
51
+ from playwright.async_api import (
52
+ BrowserContext,
53
+ )
51
54
  from pydantic import BaseModel
52
55
 
56
+ logger = logging.getLogger(__name__)
57
+
53
58
  _StructuredOutput = TypeVar("_StructuredOutput", bound=BaseModel)
54
59
 
55
60
 
@@ -552,9 +557,10 @@ class LocalBrowserWindow(BaseBrowserWindow):
552
557
  config: BrowserConfig,
553
558
  context: BrowserContext,
554
559
  ) -> None:
560
+ base_url = os.getenv("NARADA_API_BASE_URL", "https://api.narada.ai/fast/v2")
555
561
  super().__init__(
556
562
  api_key=api_key,
557
- base_url=os.getenv("NARADA_API_BASE_URL", "https://api.narada.ai/fast/v2"),
563
+ base_url=base_url,
558
564
  browser_window_id=browser_window_id,
559
565
  )
560
566
  self._browser_process_id = browser_process_id
@@ -586,9 +592,10 @@ class LocalBrowserWindow(BaseBrowserWindow):
586
592
 
587
593
  class RemoteBrowserWindow(BaseBrowserWindow):
588
594
  def __init__(self, *, browser_window_id: str, api_key: str | None = None) -> None:
595
+ base_url = os.getenv("NARADA_API_BASE_URL", "https://api.narada.ai/fast/v2")
589
596
  super().__init__(
590
597
  api_key=api_key or os.environ["NARADA_API_KEY"],
591
- base_url=os.getenv("NARADA_API_BASE_URL", "https://api.narada.ai/fast/v2"),
598
+ base_url=base_url,
592
599
  browser_window_id=browser_window_id,
593
600
  )
594
601
 
@@ -596,5 +603,51 @@ class RemoteBrowserWindow(BaseBrowserWindow):
596
603
  return f"RemoteBrowserWindow(browser_window_id={self.browser_window_id})"
597
604
 
598
605
 
606
+ class CloudBrowserWindow(BaseBrowserWindow):
607
+ """A browser window that connects to a backend-cloud browser session via CDP.
608
+
609
+ This class connects to a cloud browser session created by the backend API and provides
610
+ the same interface as other browser window classes for agent operations.
611
+ """
612
+
613
+ def __init__(
614
+ self,
615
+ *,
616
+ browser_window_id: str,
617
+ session_id: str,
618
+ api_key: str | None = None,
619
+ ) -> None:
620
+ base_url = os.getenv("NARADA_API_BASE_URL", "https://api.narada.ai/fast/v2")
621
+ super().__init__(
622
+ api_key=api_key or os.environ["NARADA_API_KEY"],
623
+ base_url=base_url,
624
+ browser_window_id=browser_window_id,
625
+ )
626
+ self._session_id = session_id
627
+
628
+ async def cleanup(self) -> None:
629
+ """Stop the cloud browser session."""
630
+ try:
631
+ async with aiohttp.ClientSession() as session:
632
+ async with session.post(
633
+ f"{self._base_url}/cloud-browser/stop-cloud-browser-session",
634
+ headers={"x-api-key": self._api_key},
635
+ json={
636
+ "session_id": self._session_id,
637
+ },
638
+ timeout=aiohttp.ClientTimeout(total=10),
639
+ ) as resp:
640
+ if resp.ok:
641
+ response_data = await resp.json()
642
+ if not response_data.get("success"):
643
+ logger.warning(
644
+ f"Failed to stop session: {response_data.get('message')}"
645
+ )
646
+ else:
647
+ logger.warning(f"Failed to stop session: {resp.status}")
648
+ except Exception as e:
649
+ logger.warning(f"Error calling stop session endpoint: {e}")
650
+
651
+
599
652
  def create_side_panel_url(config: BrowserConfig, browser_window_id: str) -> str:
600
653
  return f"chrome-extension://{config.extension_id}/sidepanel.html?browserWindowId={browser_window_id}"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes