narada 0.2.2__tar.gz → 0.2.3__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.2.2
3
+ Version: 0.2.3
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
@@ -9,7 +9,7 @@ Author-email: Narada <support@narada.ai>
9
9
  License-Expression: Apache-2.0
10
10
  Requires-Python: >=3.12
11
11
  Requires-Dist: aiohttp>=3.12.13
12
- Requires-Dist: narada-core==0.1.1
12
+ Requires-Dist: narada-core==0.1.2
13
13
  Requires-Dist: packaging==24.2
14
14
  Requires-Dist: playwright>=1.53.0
15
15
  Requires-Dist: rich>=14.0.0
@@ -1,13 +1,13 @@
1
1
  [project]
2
2
  name = "narada"
3
- version = "0.2.2"
3
+ version = "0.2.3"
4
4
  description = "Python client SDK for Narada"
5
5
  license = "Apache-2.0"
6
6
  readme = "README.md"
7
7
  authors = [{ name = "Narada", email = "support@narada.ai" }]
8
8
  requires-python = ">=3.12"
9
9
  dependencies = [
10
- "narada-core==0.1.1",
10
+ "narada-core==0.1.2",
11
11
  "aiohttp>=3.12.13",
12
12
  "playwright>=1.53.0",
13
13
  "rich>=14.0.0",
@@ -28,9 +28,9 @@ from narada_core.actions.models import (
28
28
  GetUrlResponse,
29
29
  GoToUrlRequest,
30
30
  JsonValue,
31
- PrintMessageRequest,
32
31
  PressKeyEventItem,
33
32
  PressKeyRequest,
33
+ PrintMessageRequest,
34
34
  PromptForUserInputRequest,
35
35
  PromptForUserInputResponse,
36
36
  PromptForUserInputVariable,
@@ -39,6 +39,8 @@ from narada_core.actions.models import (
39
39
  ReadGoogleSheetRequest,
40
40
  ReadGoogleSheetResponse,
41
41
  RecordedClick,
42
+ SavePdfFileRequest,
43
+ SavePdfFileResponse,
42
44
  UserApprovalRequest,
43
45
  UserApprovalResponse,
44
46
  WaitForElementRequest,
@@ -584,6 +586,14 @@ class Agent(Generic[_StructuredOutput]):
584
586
  timeout=timeout,
585
587
  )
586
588
 
589
+ async def save_pdf_file(self, *, timeout: int | None = None) -> SavePdfFileResponse:
590
+ """Saves the PDF file displayed in the current browser page."""
591
+ return await self._browser_environment()._run_extension_action(
592
+ SavePdfFileRequest(),
593
+ SavePdfFileResponse,
594
+ timeout=timeout,
595
+ )
596
+
587
597
  async def get_screenshot(
588
598
  self, *, timeout: int | None = None
589
599
  ) -> GetScreenshotResponse:
@@ -6,6 +6,7 @@ import json
6
6
  import logging
7
7
  import mimetypes
8
8
  import os
9
+ import random
9
10
  import subprocess
10
11
  import sys
11
12
  import time
@@ -1477,14 +1478,31 @@ class CloudBrowserEnvironment(BaseBrowserEnvironment):
1477
1478
 
1478
1479
  async def _initialize(self) -> None:
1479
1480
  """Create a cloud browser session and initialize the browser extension.
1481
+ Retry up to 3 times on error.
1480
1482
 
1481
1483
  Calls ``POST /cloud-browser/create-cloud-browser-session``, then connects local
1482
1484
  Playwright over CDP, opens ``login_url``, and waits for
1483
1485
  ``#narada-browser-window-id`` (extension install retries apply). ``config`` controls
1484
1486
  interactive prompts and related behavior.
1485
1487
  """
1486
- await self._start_playwright()
1488
+ max_attempts = 3
1489
+ for attempt in range(max_attempts):
1490
+ try:
1491
+ await self._initialize_once()
1492
+ return
1493
+ except Exception as error:
1494
+ await self._cleanup_failed_initialization_attempt()
1495
+ if (
1496
+ attempt == max_attempts - 1
1497
+ or self._is_non_retryable_initialization_error(error)
1498
+ ):
1499
+ raise
1500
+
1501
+ retry_backoff_with_jitter = 2 ** (attempt + 1) + random.uniform(0, 1)
1502
+ await asyncio.sleep(retry_backoff_with_jitter)
1487
1503
 
1504
+ async def _initialize_once(self) -> None:
1505
+ await self._start_playwright()
1488
1506
  request_body = {
1489
1507
  "require_extension": True,
1490
1508
  "session_name": self._session_name,
@@ -1503,61 +1521,63 @@ class CloudBrowserEnvironment(BaseBrowserEnvironment):
1503
1521
  ) as resp:
1504
1522
  if not resp.ok:
1505
1523
  error_text = await resp.text()
1506
- if resp.status == HTTPStatus.FORBIDDEN:
1507
- error = ApiErrorPayload.from_error_text(error_text)
1508
- err = RuntimeError(
1509
- f"Failed to create cloud browser session: {resp.status} {error_text}\n"
1510
- f"Endpoint URL: {endpoint_url}"
1511
- )
1512
- err.status_code = resp.status # type: ignore[attr-defined]
1513
- err.detail = error.detail # type: ignore[attr-defined]
1514
- raise err
1515
- raise RuntimeError(
1524
+ err = RuntimeError(
1516
1525
  f"Failed to create cloud browser session: {resp.status} {error_text}\n"
1517
1526
  f"Endpoint URL: {endpoint_url}"
1518
1527
  )
1528
+ err.status_code = resp.status # type: ignore[attr-defined]
1529
+ if resp.status == HTTPStatus.FORBIDDEN:
1530
+ error = ApiErrorPayload.from_error_text(error_text)
1531
+ err.detail = error.detail # type: ignore[attr-defined]
1532
+ raise err
1519
1533
  response_data = await resp.json()
1520
1534
 
1521
1535
  cdp_websocket_url = response_data["cdp_websocket_url"]
1522
1536
  session_id = response_data["session_id"]
1523
1537
  login_url = response_data["login_url"]
1524
1538
  cdp_auth_headers = response_data["cdp_auth_headers"]
1539
+ self._session_id = session_id
1525
1540
 
1526
1541
  # Connect to browser via CDP with authentication headers and log the user in.
1527
- try:
1528
- await self._initialize_cloud_browser_window(
1529
- cdp_websocket_url=cdp_websocket_url,
1530
- session_id=session_id,
1531
- login_url=login_url,
1532
- cdp_auth_headers=cdp_auth_headers,
1533
- )
1534
- except Exception:
1535
- # Clean up the session if CDP connection fails
1542
+ await self._initialize_cloud_browser_window(
1543
+ cdp_websocket_url=cdp_websocket_url,
1544
+ session_id=session_id,
1545
+ login_url=login_url,
1546
+ cdp_auth_headers=cdp_auth_headers,
1547
+ )
1548
+
1549
+ @staticmethod
1550
+ def _is_non_retryable_initialization_error(error: Exception) -> bool:
1551
+ status_code = getattr(error, "status_code", None)
1552
+ return isinstance(status_code, int) and 400 <= status_code < 500
1553
+
1554
+ async def _cleanup_failed_initialization_attempt(self) -> None:
1555
+ if self._session_id is not None:
1536
1556
  try:
1537
- async with aiohttp.ClientSession() as cleanup_session:
1538
- async with cleanup_session.post(
1539
- f"{self._base_url}/cloud-browser/stop-cloud-browser-session",
1540
- headers=self._auth_headers,
1541
- json={"session_id": session_id, "status": "failed"},
1542
- timeout=aiohttp.ClientTimeout(total=10),
1543
- ) as resp:
1544
- if resp.ok:
1545
- logging.info(
1546
- "Cleaned up session %s after CDP connection failure",
1547
- session_id,
1548
- )
1549
- else:
1550
- logging.warning(
1551
- "Failed to cleanup session %s: %s",
1552
- session_id,
1553
- resp.status,
1554
- )
1555
- except Exception as cleanup_error:
1556
- logging.warning(
1557
- "Error cleaning up session %s: %s", session_id, cleanup_error
1557
+ await _stop_cloud_browser_session(
1558
+ base_url=self._base_url,
1559
+ auth_headers=self._auth_headers,
1560
+ session_id=self._session_id,
1561
+ status="failed",
1562
+ timeout=10,
1558
1563
  )
1559
- # Re-raise the original connection error
1560
- raise
1564
+ except Exception:
1565
+ logger.exception(
1566
+ "Error cleaning up session %s (%s) after failed initialization",
1567
+ self._session_id,
1568
+ self._session_name,
1569
+ )
1570
+
1571
+ try:
1572
+ await self._stop_playwright()
1573
+ except Exception:
1574
+ logger.exception(
1575
+ "Error stopping Playwright after failed cloud browser initialization"
1576
+ )
1577
+ finally:
1578
+ self._session_id = None
1579
+ self._browser_window_id = None
1580
+ self._context = None
1561
1581
 
1562
1582
  async def _start_playwright(self) -> None:
1563
1583
  self._playwright_context_manager = async_playwright()
@@ -1874,6 +1894,7 @@ async def _stop_cloud_browser_session(
1874
1894
  base_url: str,
1875
1895
  auth_headers: dict[str, str],
1876
1896
  session_id: str,
1897
+ status: Literal["complete", "terminated", "failed", "timed_out"] = "complete",
1877
1898
  timeout: int | None = None,
1878
1899
  ) -> None:
1879
1900
  try:
@@ -1881,7 +1902,7 @@ async def _stop_cloud_browser_session(
1881
1902
  async with session.post(
1882
1903
  f"{base_url}/cloud-browser/stop-cloud-browser-session",
1883
1904
  headers=auth_headers,
1884
- json={"session_id": session_id},
1905
+ json={"session_id": session_id, "status": status},
1885
1906
  timeout=aiohttp.ClientTimeout(total=timeout or 40),
1886
1907
  ) as resp:
1887
1908
  if resp.ok:
@@ -140,3 +140,33 @@ async def test_execute_javascript_on_page_dispatches_extension_action(
140
140
  "name": "execute_javascript_on_page",
141
141
  "code": "(() => ({ title: document.title, count: 3 }))()",
142
142
  }
143
+
144
+
145
+ @pytest.mark.asyncio
146
+ async def test_save_pdf_file_dispatches_extension_action(
147
+ monkeypatch: pytest.MonkeyPatch,
148
+ ) -> None:
149
+ fake_session = _FakeSession(
150
+ [
151
+ {
152
+ "status": "success",
153
+ "data": '{"base64_content":"JVBERi0xLjQ=","name":"invoice.pdf","mime_type":"application/pdf","timestamp":"2026-06-22T00:00:00.000Z"}',
154
+ }
155
+ ]
156
+ )
157
+ monkeypatch.setattr(
158
+ "narada.environment.aiohttp.ClientSession", lambda: fake_session
159
+ )
160
+ agent = Agent(
161
+ environment=RemoteBrowserEnvironment(
162
+ browser_window_id="bw-1", api_key="test-key"
163
+ )
164
+ )
165
+
166
+ result = await agent.save_pdf_file()
167
+
168
+ assert result.name == "invoice.pdf"
169
+ assert result.mime_type == "application/pdf"
170
+ assert result.base64_content == "JVBERi0xLjQ="
171
+ assert "base64_content" not in result.model_dump()
172
+ assert fake_session.post_bodies[0]["action"] == {"name": "save_pdf_file"}
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes