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.
- {narada-0.2.2 → narada-0.2.3}/PKG-INFO +2 -2
- {narada-0.2.2 → narada-0.2.3}/pyproject.toml +2 -2
- {narada-0.2.2 → narada-0.2.3}/src/narada/agent.py +11 -1
- {narada-0.2.2 → narada-0.2.3}/src/narada/environment.py +65 -44
- {narada-0.2.2 → narada-0.2.3}/tests/test_window_human_interaction.py +30 -0
- {narada-0.2.2 → narada-0.2.3}/.gitignore +0 -0
- {narada-0.2.2 → narada-0.2.3}/README.md +0 -0
- {narada-0.2.2 → narada-0.2.3}/src/narada/__init__.py +0 -0
- {narada-0.2.2 → narada-0.2.3}/src/narada/config.py +0 -0
- {narada-0.2.2 → narada-0.2.3}/src/narada/py.typed +0 -0
- {narada-0.2.2 → narada-0.2.3}/src/narada/utils.py +0 -0
- {narada-0.2.2 → narada-0.2.3}/src/narada/version.py +0 -0
- {narada-0.2.2 → narada-0.2.3}/tests/test_agent.py +0 -0
- {narada-0.2.2 → narada-0.2.3}/tests/test_browser_environment_login.py +0 -0
- {narada-0.2.2 → narada-0.2.3}/tests/test_client.py +0 -0
- {narada-0.2.2 → narada-0.2.3}/tests/test_cloud_browser.py +0 -0
- {narada-0.2.2 → narada-0.2.3}/tests/test_input_variables.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: narada
|
|
3
|
-
Version: 0.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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
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
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
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
|
-
|
|
1560
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|