narada 0.1.27__py3-none-any.whl → 0.1.29__py3-none-any.whl

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/__init__.py CHANGED
@@ -1,5 +1,3 @@
1
- import importlib.metadata
2
-
3
1
  from narada_core.errors import (
4
2
  NaradaError,
5
3
  NaradaExtensionMissingError,
@@ -11,17 +9,11 @@ from narada_core.errors import (
11
9
  from narada_core.models import Agent, File, Response, ResponseContent
12
10
 
13
11
  from narada.client import Narada
14
- from narada.config import BrowserConfig
12
+ from narada.config import BrowserConfig, ProxyConfig
15
13
  from narada.utils import download_file, render_html
14
+ from narada.version import __version__
16
15
  from narada.window import LocalBrowserWindow, RemoteBrowserWindow
17
16
 
18
- # Get version from package metadata
19
- try:
20
- __version__ = importlib.metadata.version("narada")
21
- except Exception:
22
- # Fallback version if package metadata is not available
23
- __version__ = "unknown"
24
-
25
17
  __all__ = [
26
18
  "__version__",
27
19
  "Agent",
@@ -36,6 +28,7 @@ __all__ = [
36
28
  "NaradaInitializationError",
37
29
  "NaradaTimeoutError",
38
30
  "NaradaUnsupportedBrowserError",
31
+ "ProxyConfig",
39
32
  "RemoteBrowserWindow",
40
33
  "render_html",
41
34
  "Response",
narada/client.py CHANGED
@@ -9,6 +9,8 @@ from dataclasses import dataclass
9
9
  from typing import Any
10
10
  from uuid import uuid4
11
11
 
12
+ import aiohttp
13
+ import semver
12
14
  from narada_core.errors import (
13
15
  NaradaExtensionMissingError,
14
16
  NaradaExtensionUnauthenticatedError,
@@ -16,8 +18,11 @@ from narada_core.errors import (
16
18
  NaradaTimeoutError,
17
19
  NaradaUnsupportedBrowserError,
18
20
  )
21
+ from narada_core.models import _SdkConfig
19
22
  from playwright._impl._errors import Error as PlaywrightError
20
23
  from playwright.async_api import (
24
+ Browser,
25
+ CDPSession,
21
26
  ElementHandle,
22
27
  Page,
23
28
  Playwright,
@@ -29,8 +34,9 @@ from playwright.async_api import (
29
34
  from playwright.async_api._context_manager import PlaywrightContextManager
30
35
  from rich.console import Console
31
36
 
32
- from narada.config import BrowserConfig
37
+ from narada.config import BrowserConfig, ProxyConfig
33
38
  from narada.utils import assert_never
39
+ from narada.version import __version__
34
40
  from narada.window import LocalBrowserWindow, create_side_panel_url
35
41
 
36
42
 
@@ -58,6 +64,8 @@ class Narada:
58
64
  self._console = Console()
59
65
 
60
66
  async def __aenter__(self) -> Narada:
67
+ await self._validate_sdk_config()
68
+
61
69
  self._playwright_context_manager = async_playwright()
62
70
  self._playwright = await self._playwright_context_manager.__aenter__()
63
71
  return self
@@ -70,6 +78,40 @@ class Narada:
70
78
  self._playwright_context_manager = None
71
79
  self._playwright = None
72
80
 
81
+ async def _fetch_sdk_config(self) -> _SdkConfig | None:
82
+ base_url = os.getenv("NARADA_API_BASE_URL", "https://api.narada.ai/fast/v2")
83
+ url = f"{base_url}/sdk/config"
84
+
85
+ try:
86
+ async with aiohttp.ClientSession() as session:
87
+ async with session.get(
88
+ url, headers={"x-api-key": self._api_key}
89
+ ) as resp:
90
+ if not resp.ok:
91
+ logging.warning(
92
+ "Failed to fetch SDK config: %s %s",
93
+ resp.status,
94
+ await resp.text(),
95
+ )
96
+ return None
97
+
98
+ return _SdkConfig.model_validate(await resp.json())
99
+ except Exception as e:
100
+ logging.warning("Failed to fetch SDK config: %s", e)
101
+ return None
102
+
103
+ async def _validate_sdk_config(self) -> None:
104
+ config = await self._fetch_sdk_config()
105
+ if config is None:
106
+ return
107
+
108
+ package_config = config.packages["narada"]
109
+ if semver.compare(__version__, package_config.min_required_version) < 0:
110
+ raise RuntimeError(
111
+ f"narada<={__version__} is not supported. Please upgrade to version "
112
+ f"{package_config.min_required_version} or higher."
113
+ )
114
+
73
115
  async def open_and_initialize_browser_window(
74
116
  self, config: BrowserConfig | None = None
75
117
  ) -> LocalBrowserWindow:
@@ -106,6 +148,13 @@ class Narada:
106
148
 
107
149
  config = config or BrowserConfig()
108
150
 
151
+ if config.proxy is not None:
152
+ raise ValueError(
153
+ "Proxy configuration is not supported for `initialize_in_existing_browser_window`. "
154
+ "Proxy settings must be specified when launching Chrome. "
155
+ "Use `open_and_initialize_browser_window` instead."
156
+ )
157
+
109
158
  browser = await playwright.chromium.connect_over_cdp(config.cdp_url)
110
159
 
111
160
  # Generate a unique tag for the initialization URL
@@ -153,6 +202,13 @@ class Narada:
153
202
  window_tag = uuid4().hex
154
203
  tagged_initialization_url = f"{config.initialization_url}?t={window_tag}"
155
204
 
205
+ # When proxy auth is needed, launch with about:blank to avoid Chrome's startup auth prompt.
206
+ # We'll set up the CDP auth handler and then navigate to the init URL.
207
+ proxy_requires_auth = (
208
+ config.proxy is not None and config.proxy.requires_authentication
209
+ )
210
+ launch_url = "about:blank" if proxy_requires_auth else tagged_initialization_url
211
+
156
212
  browser_args = [
157
213
  f"--user-data-dir={config.user_data_dir}",
158
214
  f"--profile-directory={config.profile_directory}",
@@ -160,11 +216,20 @@ class Narada:
160
216
  "--no-default-browser-check",
161
217
  "--no-first-run",
162
218
  "--new-window",
163
- tagged_initialization_url,
164
- # TODO: This is needed if we don't use CDP but let Playwright manage the browser.
165
- # "--disable-blink-features=AutomationControlled",
219
+ launch_url,
166
220
  ]
167
221
 
222
+ # Add proxy arguments if configured.
223
+ if config.proxy is not None:
224
+ config.proxy.validate()
225
+ browser_args.append(f"--proxy-server={config.proxy.server}")
226
+
227
+ if config.proxy.bypass:
228
+ browser_args.append(f"--proxy-bypass-list={config.proxy.bypass}")
229
+
230
+ if config.proxy.ignore_cert_errors:
231
+ browser_args.append("--ignore-certificate-errors")
232
+
168
233
  # Launch an independent browser process which will not be killed when the current program
169
234
  # exits.
170
235
  if sys.platform == "win32":
@@ -195,6 +260,14 @@ class Narada:
195
260
  browser_window_id = None
196
261
  side_panel_page = None
197
262
  max_cdp_connect_attempts = 10
263
+
264
+ # Track whether we've already navigated from about:blank to the initialization URL.
265
+ # This is only relevant when proxy auth is enabled, where we launch with about:blank
266
+ # to set up CDP auth handlers before any network traffic. We must only navigate once,
267
+ # because on retry iterations context.pages[0] could be any page (side panel, devtools,
268
+ # etc.) and navigating it would break the initialization flow.
269
+ did_initial_navigation = False
270
+
198
271
  for attempt in range(max_cdp_connect_attempts):
199
272
  try:
200
273
  browser = await playwright.chromium.connect_over_cdp(config.cdp_url)
@@ -206,8 +279,23 @@ class Narada:
206
279
  await asyncio.sleep(2)
207
280
  continue
208
281
 
209
- # Grab the browser window ID from the page we just opened.
210
282
  context = browser.contexts[0]
283
+
284
+ # If proxy auth is needed, set up the handler at browser level then navigate to the
285
+ # initialization page. After navigation succeeds, Chrome has cached the proxy
286
+ # credentials, so we can detach the CDP session.
287
+ if proxy_requires_auth and not did_initial_navigation:
288
+ proxy_cdp_session = (
289
+ await self._setup_proxy_authentication_browser_level(
290
+ browser, config.proxy
291
+ )
292
+ )
293
+ blank_page = context.pages[0]
294
+ await blank_page.goto(tagged_initialization_url)
295
+ await proxy_cdp_session.detach()
296
+ did_initial_navigation = True
297
+
298
+ # Grab the browser window ID from the page we just opened.
211
299
  initialization_page = next(
212
300
  (p for p in context.pages if p.url == tagged_initialization_url), None
213
301
  )
@@ -375,6 +463,73 @@ class Narada:
375
463
  initialization_page
376
464
  )
377
465
 
466
+ async def _setup_proxy_authentication_browser_level(
467
+ self, browser: Browser, proxy_config: ProxyConfig
468
+ ) -> CDPSession:
469
+ """Sets up proxy authentication handling via CDP at the browser level.
470
+
471
+ This uses a browser-level CDP session which can intercept auth challenges before they reach
472
+ individual pages, preventing Chrome from showing the proxy authentication dialog.
473
+
474
+ Chrome caches proxy credentials for the session after the first successful authentication.
475
+ The caller should detach the returned CDP session after the first navigation succeeds.
476
+ """
477
+ cdp_session = await browser.new_browser_cdp_session()
478
+
479
+ # Enable Fetch domain with a catch-all pattern to intercept auth challenges.
480
+ await cdp_session.send(
481
+ "Fetch.enable",
482
+ {
483
+ "handleAuthRequests": True,
484
+ "patterns": [{"urlPattern": "*"}],
485
+ },
486
+ )
487
+
488
+ async def handle_auth(params: dict[str, Any]) -> None:
489
+ request_id = params.get("requestId")
490
+ auth_challenge = params.get("authChallenge", {})
491
+
492
+ # Only handle proxy auth challenges
493
+ if auth_challenge.get("source") != "Proxy":
494
+ return
495
+
496
+ try:
497
+ await cdp_session.send(
498
+ "Fetch.continueWithAuth",
499
+ {
500
+ "requestId": request_id,
501
+ "authChallengeResponse": {
502
+ "response": "ProvideCredentials",
503
+ "username": proxy_config.username,
504
+ "password": proxy_config.password,
505
+ },
506
+ },
507
+ )
508
+ logging.debug("Browser-level proxy authentication credentials provided")
509
+ except Exception as e:
510
+ logging.error("Failed to respond to proxy auth challenge: %s", e)
511
+
512
+ async def handle_request_paused(params: dict[str, Any]) -> None:
513
+ # Continue all paused requests immediately
514
+ request_id = params.get("requestId")
515
+ try:
516
+ await cdp_session.send(
517
+ "Fetch.continueRequest", {"requestId": request_id}
518
+ )
519
+ except Exception:
520
+ pass
521
+
522
+ cdp_session.on(
523
+ "Fetch.authRequired",
524
+ lambda params: asyncio.create_task(handle_auth(params)),
525
+ )
526
+ cdp_session.on(
527
+ "Fetch.requestPaused",
528
+ lambda params: asyncio.create_task(handle_request_paused(params)),
529
+ )
530
+
531
+ return cdp_session
532
+
378
533
  async def _fix_download_behavior(self, side_panel_page: Page) -> None:
379
534
  """Reverts the download behavior to the default behavior for the extension, otherwise our
380
535
  extension cannot download files.
narada/config.py CHANGED
@@ -22,6 +22,50 @@ def _default_user_data_dir() -> str:
22
22
  return str(Path("~/.config/narada/user-data-dirs/default").expanduser())
23
23
 
24
24
 
25
+ @dataclass
26
+ class ProxyConfig:
27
+ """Configuration for HTTP/HTTPS/SOCKS5 proxy.
28
+
29
+ Args:
30
+ server: Proxy server URL. HTTP and SOCKS proxies are supported, for example
31
+ "http://myproxy.com:3128" or "socks5://myproxy.com:3128".
32
+ Short form "myproxy.com:3128" is considered an HTTP proxy.
33
+ username: Optional username for proxy authentication.
34
+ password: Optional password for proxy authentication.
35
+ bypass: Optional comma-separated domains to bypass proxy,
36
+ for example ".com, chromium.org, .domain.com".
37
+ ignore_cert_errors: If True, ignore SSL certificate errors. Required for proxies that
38
+ perform HTTPS inspection (MITM). Use with caution.
39
+ """
40
+
41
+ server: str
42
+ username: str | None = None
43
+ password: str | None = None
44
+ bypass: str | None = None
45
+ ignore_cert_errors: bool = False
46
+
47
+ @property
48
+ def requires_authentication(self) -> bool:
49
+ """Returns True if proxy requires authentication."""
50
+ return self.username is not None and self.password is not None
51
+
52
+ def validate(self) -> None:
53
+ """Validates the proxy configuration.
54
+
55
+ Raises:
56
+ ValueError: If configuration is invalid.
57
+ """
58
+ if not self.server:
59
+ raise ValueError("Proxy server cannot be empty")
60
+
61
+ # Validate that if one credential is provided, both are provided
62
+ if (self.username is None) != (self.password is None):
63
+ raise ValueError(
64
+ "Both username and password must be provided for proxy authentication, "
65
+ "or neither should be provided"
66
+ )
67
+
68
+
25
69
  @dataclass
26
70
  class BrowserConfig:
27
71
  executable_path: str = field(default_factory=_default_executable_path)
@@ -32,6 +76,7 @@ class BrowserConfig:
32
76
  initialization_url: str = "https://app.narada.ai/initialize"
33
77
  extension_id: str = "bhioaidlggjdkheaajakomifblpjmokn"
34
78
  interactive: bool = True
79
+ proxy: ProxyConfig | None = None
35
80
 
36
81
  @property
37
82
  def cdp_url(self) -> str:
narada/version.py ADDED
@@ -0,0 +1,7 @@
1
+ import importlib.metadata
2
+
3
+ try:
4
+ __version__ = importlib.metadata.version("narada")
5
+ except Exception:
6
+ # Fallback version if package metadata is not available
7
+ __version__ = "unknown"
narada/window.py CHANGED
@@ -4,12 +4,15 @@ import time
4
4
  from abc import ABC
5
5
  from http import HTTPStatus
6
6
  from pathlib import Path
7
- from typing import IO, Any, TypeVar, overload
7
+ from typing import IO, Any, Optional, TypeVar, overload
8
8
 
9
9
  import aiohttp
10
10
  from narada_core.actions.models import (
11
11
  AgenticSelectorAction,
12
12
  AgenticSelectorRequest,
13
+ AgenticSelectorResponse,
14
+ AgenticMouseActionRequest,
15
+ AgenticMouseAction,
13
16
  AgenticSelectors,
14
17
  AgentResponse,
15
18
  AgentUsage,
@@ -21,6 +24,7 @@ from narada_core.actions.models import (
21
24
  ReadGoogleSheetRequest,
22
25
  ReadGoogleSheetResponse,
23
26
  WriteGoogleSheetRequest,
27
+ RecordedClick,
24
28
  )
25
29
  from narada_core.errors import (
26
30
  NaradaAgentTimeoutError_INTERNAL_DO_NOT_USE,
@@ -325,14 +329,47 @@ class BaseBrowserWindow(ABC):
325
329
  fallback_operator_query: str,
326
330
  # Larger default timeout because Operator can take a bit to run.
327
331
  timeout: int | None = 60,
328
- ) -> None:
332
+ ) -> AgenticSelectorResponse:
329
333
  """Performs an action on an element specified by the given selectors, falling back to using
330
334
  the Operator agent if the selectors fail to match a unique element.
331
335
  """
332
- return await self._run_extension_action(
336
+ response_model = (
337
+ AgenticSelectorResponse
338
+ if action["type"] in {"get_text", "get_property"}
339
+ else None
340
+ )
341
+ result = await self._run_extension_action(
333
342
  AgenticSelectorRequest(
334
343
  action=action,
335
344
  selectors=selectors,
345
+ response_model=response_model,
346
+ fallback_operator_query=fallback_operator_query,
347
+ ),
348
+ timeout=timeout,
349
+ )
350
+
351
+ if result is None:
352
+ return {"value": None}
353
+
354
+ return result
355
+
356
+ async def agentic_mouse_action(
357
+ self,
358
+ *,
359
+ action: AgenticMouseAction,
360
+ recorded_click: RecordedClick,
361
+ fallback_operator_query: str,
362
+ resize_window: bool = True,
363
+ timeout: int | None = 60,
364
+ ) -> None:
365
+ """Performs a mouse action at the specified click coordinates, falling back to using
366
+ the Operator agent if the click fails.
367
+ """
368
+ return await self._run_extension_action(
369
+ AgenticMouseActionRequest(
370
+ action=action,
371
+ recorded_click=recorded_click,
372
+ resize_window=resize_window,
336
373
  fallback_operator_query=fallback_operator_query,
337
374
  ),
338
375
  timeout=timeout,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: narada
3
- Version: 0.1.27
3
+ Version: 0.1.29
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,9 +9,10 @@ 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.0.5
12
+ Requires-Dist: narada-core==0.0.7
13
13
  Requires-Dist: playwright>=1.53.0
14
14
  Requires-Dist: rich>=14.0.0
15
+ Requires-Dist: semver>=3.0.4
15
16
  Description-Content-Type: text/markdown
16
17
 
17
18
  <p align="center">
@@ -0,0 +1,10 @@
1
+ narada/__init__.py,sha256=2AnpZVQpkh4-onnvbgHX1Wlq1Fa8ya4vAzy2fbS0bCY,968
2
+ narada/client.py,sha256=_nTRHkiYt7lmUgO0HibVagrPXf523LCbWx8JmuVGgoo,21622
3
+ narada/config.py,sha256=S0B8GNd-0td_69oKaPN60WAq_ODeYU7avE_KAxN5vCg,3052
4
+ narada/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ narada/utils.py,sha256=gdLwNMXPpRohDcIIe0cB3KhvZ8X1QfAlKVh1sXWeJmk,1284
6
+ narada/version.py,sha256=kwW6yy0_4Pf3kt888eeCG0VwBb2L2rCkrkpdZEC_3rA,193
7
+ narada/window.py,sha256=hWkL637Aj7ZoJNFkvg92lSHRBaK72FY2WcCoyxzXKyk,18928
8
+ narada-0.1.29.dist-info/METADATA,sha256=ZRJ6lY2UYwj-kMsCOL5peS1mjni9Kr0Y5ROOfK4uBPE,5146
9
+ narada-0.1.29.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
10
+ narada-0.1.29.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.27.0
2
+ Generator: hatchling 1.28.0
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,9 +0,0 @@
1
- narada/__init__.py,sha256=TFEfS_6cCaD2mLUDBTUOUar3wJqjz1wyyd7sx43MYUk,1127
2
- narada/client.py,sha256=jksBBWdSJP88fP1DpLYoyJNNdgkUMrEKlcuYenARzaI,15265
3
- narada/config.py,sha256=tvj16P_qAHOFB2O_VlrcizA8SrbmS_Nmkm2r7ltG-VM,1345
4
- narada/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
- narada/utils.py,sha256=gdLwNMXPpRohDcIIe0cB3KhvZ8X1QfAlKVh1sXWeJmk,1284
6
- narada/window.py,sha256=OGuEs778xjxHivzWlJIIHKZPqGd4Sn9_YVArt_8TYoU,17762
7
- narada-0.1.27.dist-info/METADATA,sha256=_qPEc-3NJIO04Jt9sGUSLeoFTG-ZeoQxJ-5iKhwaPP4,5117
8
- narada-0.1.27.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
9
- narada-0.1.27.dist-info/RECORD,,