nora-sdk 0.4.0__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.
nora_sdk-0.4.0/LICENSE ADDED
@@ -0,0 +1,5 @@
1
+ Copyright (c) Valisoft. All rights reserved.
2
+
3
+ This software (the "NORA SDK") is provided to NORA customers to build and run
4
+ automations ("robots") on the NORA platform. Redistribution or modification
5
+ outside that purpose is not permitted without written permission from Valisoft.
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.4
2
+ Name: nora-sdk
3
+ Version: 0.4.0
4
+ Summary: Client SDK + dev CLI to build and debug robots on NORA (Robots Center).
5
+ Author: Valisoft
6
+ License: Proprietary
7
+ Project-URL: Homepage, https://nora.valisoftconsulting.com
8
+ Keywords: nora,rpa,automation,sdk,robots
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: Microsoft :: Windows
11
+ Classifier: Operating System :: MacOS
12
+ Classifier: License :: Other/Proprietary License
13
+ Classifier: Intended Audience :: Developers
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: httpx<1.0,>=0.27
18
+ Dynamic: license-file
19
+
20
+ # nora-sdk
21
+
22
+ Client SDK + `nora` developer CLI to build and debug **robots** on NORA
23
+ (Robots Center). Install it to develop locally; in production the NORA agent
24
+ provides the same SDK automatically, so your robot code is identical.
25
+
26
+ ```bash
27
+ pip install nora-sdk
28
+ ```
29
+
30
+ ## Uso en el robot
31
+ ```python
32
+ from nora_agent import sdk
33
+
34
+ item = sdk.get_queue_item("RPA-Challenge") # consume una cola
35
+ cred = sdk.get_asset("API_KEY") # lee un asset (secreto)
36
+ sdk.log("info", "hola") # log al dashboard
37
+ sdk.update_progress(50, "a mitad")
38
+ ```
39
+
40
+ ## Desarrollo local con breakpoints
41
+ ```bash
42
+ nora login
43
+ nora dev env --write .env # NORA_API_URL + token de dev (dev/staging, no prod)
44
+ # apunta tu IDE a .env y debuggea, o:
45
+ nora dev run main.py
46
+ ```
47
+
48
+ ## Seguridad
49
+ - El token solo viaja por **HTTPS** (rechaza http salvo localhost).
50
+ - Los nombres en la URL van **percent-encoded** (sin inyección de path/query).
51
+ - Sin `shell`, sin `eval`/`exec`. Dependencia única: `httpx`.
52
+ - El token de dev está limitado a `dev`/`staging`: nunca lee secretos de producción.
53
+ - La sesión se guarda en `~/.nora/credentials.json` con permisos solo-dueño.
@@ -0,0 +1,34 @@
1
+ # nora-sdk
2
+
3
+ Client SDK + `nora` developer CLI to build and debug **robots** on NORA
4
+ (Robots Center). Install it to develop locally; in production the NORA agent
5
+ provides the same SDK automatically, so your robot code is identical.
6
+
7
+ ```bash
8
+ pip install nora-sdk
9
+ ```
10
+
11
+ ## Uso en el robot
12
+ ```python
13
+ from nora_agent import sdk
14
+
15
+ item = sdk.get_queue_item("RPA-Challenge") # consume una cola
16
+ cred = sdk.get_asset("API_KEY") # lee un asset (secreto)
17
+ sdk.log("info", "hola") # log al dashboard
18
+ sdk.update_progress(50, "a mitad")
19
+ ```
20
+
21
+ ## Desarrollo local con breakpoints
22
+ ```bash
23
+ nora login
24
+ nora dev env --write .env # NORA_API_URL + token de dev (dev/staging, no prod)
25
+ # apunta tu IDE a .env y debuggea, o:
26
+ nora dev run main.py
27
+ ```
28
+
29
+ ## Seguridad
30
+ - El token solo viaja por **HTTPS** (rechaza http salvo localhost).
31
+ - Los nombres en la URL van **percent-encoded** (sin inyección de path/query).
32
+ - Sin `shell`, sin `eval`/`exec`. Dependencia única: `httpx`.
33
+ - El token de dev está limitado a `dev`/`staging`: nunca lee secretos de producción.
34
+ - La sesión se guarda en `~/.nora/credentials.json` con permisos solo-dueño.
@@ -0,0 +1,3 @@
1
+ """NORA Agent — lightweight RPA bot runner."""
2
+
3
+ __version__ = "0.4.0"
@@ -0,0 +1,276 @@
1
+ """``nora`` developer CLI — run robots locally without embedding credentials.
2
+
3
+ Commands:
4
+ nora login Authenticate and store a session (refresh token).
5
+ nora dev run <entry.py> Mint a short-lived dev token and run the robot with
6
+ it injected, so the code is identical to production
7
+ and never contains a credential.
8
+ nora dev env Print/write env vars to run+debug from an IDE.
9
+ nora logout Forget the stored session.
10
+
11
+ The dev token is restricted to non-production environments: a developer can
12
+ never read production secrets from their machine.
13
+
14
+ No third-party deps beyond httpx (kept minimal on purpose). No shell, no eval.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import getpass
21
+ import json
22
+ import os
23
+ import subprocess
24
+ import sys
25
+ from pathlib import Path
26
+ from urllib.parse import urlparse
27
+
28
+ import httpx
29
+
30
+ DEFAULT_API_URL = os.environ.get("NORA_API_URL", "https://nora-api.valisoftconsulting.com/api/v1")
31
+ _CONFIG_DIR = Path.home() / ".nora"
32
+ _CONFIG_FILE = _CONFIG_DIR / "credentials.json"
33
+ _LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
34
+
35
+
36
+ def _echo(msg: str) -> None:
37
+ print(msg)
38
+
39
+
40
+ def _err(msg: str) -> None:
41
+ print(msg, file=sys.stderr)
42
+
43
+
44
+ def _require_https(api_url: str) -> None:
45
+ """Refuse to send credentials/tokens to a non-https endpoint (except localhost)."""
46
+ parsed = urlparse(api_url)
47
+ host = (parsed.hostname or "").lower()
48
+ if parsed.scheme != "https" and host not in _LOCAL_HOSTS:
49
+ raise SystemExit(
50
+ f"Refusing to use a non-https API URL ({api_url}). "
51
+ "Credentials must travel over TLS."
52
+ )
53
+
54
+
55
+ def _save_session(api_url: str, refresh_token: str) -> None:
56
+ _CONFIG_DIR.mkdir(parents=True, exist_ok=True)
57
+ # Write with owner-only perms from the start (avoid a brief world-readable window).
58
+ fd = os.open(str(_CONFIG_FILE), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
59
+ with os.fdopen(fd, "w") as f:
60
+ json.dump({"api_url": api_url, "refresh_token": refresh_token}, f)
61
+ try:
62
+ os.chmod(_CONFIG_FILE, 0o600)
63
+ except OSError:
64
+ pass
65
+
66
+
67
+ def _load_session() -> dict | None:
68
+ if not _CONFIG_FILE.exists():
69
+ return None
70
+ try:
71
+ return json.loads(_CONFIG_FILE.read_text())
72
+ except (OSError, ValueError):
73
+ return None
74
+
75
+
76
+ def _access_token_from_refresh(api_url: str, refresh_token: str) -> str:
77
+ """Exchange the stored refresh token for a fresh access token."""
78
+ _require_https(api_url)
79
+ resp = httpx.post(
80
+ f"{api_url}/auth/refresh", json={"refresh_token": refresh_token}, timeout=30
81
+ )
82
+ resp.raise_for_status()
83
+ return resp.json()["data"]["access_token"]
84
+
85
+
86
+ def cmd_login(args: argparse.Namespace) -> int:
87
+ api_url = args.api_url or DEFAULT_API_URL
88
+ _require_https(api_url)
89
+ email = args.email or input("Email: ")
90
+ password = getpass.getpass("Password: ")
91
+
92
+ with httpx.Client(timeout=30) as client:
93
+ resp = client.post(f"{api_url}/auth/login", json={"email": email, "password": password})
94
+ if resp.status_code != 200:
95
+ _err(f"Login failed: {resp.text}")
96
+ return 1
97
+ data = resp.json()["data"]
98
+
99
+ # MFA-pending: the API returns {"requires_mfa": true, "mfa_token": ...}.
100
+ if isinstance(data, dict) and data.get("requires_mfa"):
101
+ code = input("MFA code: ").strip()
102
+ resp = client.post(
103
+ f"{api_url}/auth/mfa/login",
104
+ json={"mfa_token": data["mfa_token"], "code": code},
105
+ )
106
+ if resp.status_code != 200:
107
+ _err(f"MFA failed: {resp.text}")
108
+ return 1
109
+ data = resp.json()["data"]
110
+
111
+ if isinstance(data, dict) and data.get("org_token"):
112
+ _err(
113
+ "Your account belongs to multiple organizations. "
114
+ "Select one in the web app, then log in here from that org."
115
+ )
116
+ return 1
117
+
118
+ # The refresh token is delivered as an HttpOnly cookie.
119
+ refresh = client.cookies.get("nora_refresh_token") or (
120
+ data.get("refresh_token") if isinstance(data, dict) else None
121
+ )
122
+ if not refresh:
123
+ _err("Could not obtain a session token.")
124
+ return 1
125
+
126
+ _save_session(api_url, refresh)
127
+ _echo(f"Logged in. Session saved to {_CONFIG_FILE}")
128
+ return 0
129
+
130
+
131
+ def cmd_logout(_args: argparse.Namespace) -> int:
132
+ if _CONFIG_FILE.exists():
133
+ _CONFIG_FILE.unlink()
134
+ _echo("Logged out.")
135
+ return 0
136
+
137
+
138
+ def _mint_dev_token(args: argparse.Namespace) -> tuple[str, str] | None:
139
+ """Return (api_url, dev_token) for the current session, or None on error."""
140
+ session = _load_session()
141
+ if not session:
142
+ _err("Not logged in. Run 'nora login' first.")
143
+ return None
144
+ api_url = session["api_url"]
145
+ _require_https(api_url)
146
+ try:
147
+ access_token = _access_token_from_refresh(api_url, session["refresh_token"])
148
+ except httpx.HTTPError as e:
149
+ _err(f"Session expired ({e}). Run 'nora login' again.")
150
+ return None
151
+
152
+ body: dict = {"environment": args.environment, "ttl_seconds": args.ttl}
153
+ if getattr(args, "assets", None):
154
+ body["assets"] = [a.strip() for a in args.assets.split(",") if a.strip()]
155
+
156
+ resp = httpx.post(
157
+ f"{api_url}/dev/token",
158
+ json=body,
159
+ headers={"Authorization": f"Bearer {access_token}"},
160
+ timeout=30,
161
+ )
162
+ if resp.status_code != 200:
163
+ _err(f"Could not mint dev token: {resp.text}")
164
+ return None
165
+ return api_url, resp.json()["data"]["token"]
166
+
167
+
168
+ def cmd_dev_run(args: argparse.Namespace) -> int:
169
+ entry = Path(args.entry)
170
+ if not entry.exists():
171
+ _err(f"Entry file not found: {entry}")
172
+ return 1
173
+ minted = _mint_dev_token(args)
174
+ if not minted:
175
+ return 1
176
+ api_url, dev_token = minted
177
+
178
+ # Run the robot with the dev token injected exactly like the agent does in
179
+ # production. The robot's code is identical and holds no credential.
180
+ # No shell — args are passed as a list, so nothing in `entry`/`script_args`
181
+ # can be interpreted by a shell.
182
+ env = dict(os.environ)
183
+ env["NORA_API_URL"] = api_url
184
+ env["NORA_EXEC_TOKEN"] = dev_token
185
+ env["NORA_MACHINE_TOKEN"] = dev_token # SDK backward-compat
186
+ env.pop("NORA_ASSETS", None)
187
+
188
+ _echo(f"Running {entry} against {args.environment} (dev token, {args.ttl}s)")
189
+ proc = subprocess.run([sys.executable, str(entry), *args.script_args], env=env)
190
+ return proc.returncode
191
+
192
+
193
+ def cmd_dev_env(args: argparse.Namespace) -> int:
194
+ """Print (or write) the env vars to run/debug a robot from an IDE.
195
+
196
+ Put these in your IDE's run/debug configuration (VS Code envFile, PyCharm
197
+ env vars, …) and set breakpoints — the SDK will pull live data from the
198
+ Robots Center using a short-lived dev token (dev/staging only).
199
+ """
200
+ minted = _mint_dev_token(args)
201
+ if not minted:
202
+ return 1
203
+ api_url, dev_token = minted
204
+
205
+ pairs = [
206
+ ("NORA_API_URL", api_url),
207
+ ("NORA_EXEC_TOKEN", dev_token),
208
+ ("NORA_MACHINE_TOKEN", dev_token), # SDK backward-compat
209
+ ]
210
+ if args.format == "powershell":
211
+ lines = [f'$env:{k} = "{v}"' for k, v in pairs]
212
+ elif args.format == "bash":
213
+ lines = [f'export {k}="{v}"' for k, v in pairs]
214
+ else: # dotenv
215
+ lines = [f"{k}={v}" for k, v in pairs]
216
+ body = "\n".join(lines) + "\n"
217
+
218
+ if args.write:
219
+ out = Path(args.write)
220
+ fd = os.open(str(out), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
221
+ with os.fdopen(fd, "w") as f:
222
+ f.write(body)
223
+ _echo(
224
+ f"Wrote {out} (env={args.environment}, ttl={args.ttl}s). "
225
+ "Point your IDE's run config at it and set breakpoints."
226
+ )
227
+ _echo("Add this file to .gitignore — it holds a token.")
228
+ else:
229
+ # Plain stdout (no markup) so it can be piped/eval'd.
230
+ print(body, end="")
231
+ return 0
232
+
233
+
234
+ def build_parser() -> argparse.ArgumentParser:
235
+ parser = argparse.ArgumentParser(prog="nora", description="NORA developer CLI")
236
+ sub = parser.add_subparsers(dest="command", required=True)
237
+
238
+ p_login = sub.add_parser("login", help="Authenticate and store a session")
239
+ p_login.add_argument("--email")
240
+ p_login.add_argument("--api-url", dest="api_url")
241
+ p_login.set_defaults(func=cmd_login)
242
+
243
+ sub.add_parser("logout", help="Forget the stored session").set_defaults(func=cmd_logout)
244
+
245
+ p_dev = sub.add_parser("dev", help="Local development commands")
246
+ dev_sub = p_dev.add_subparsers(dest="dev_command", required=True)
247
+ p_run = dev_sub.add_parser("run", help="Run a robot locally with a dev token")
248
+ p_run.add_argument("entry", help="Path to the robot entry file (e.g. main.py)")
249
+ p_run.add_argument("--environment", "-e", default="dev", choices=["dev", "staging"])
250
+ p_run.add_argument("--assets", help="Comma-separated asset names to scope the token to")
251
+ p_run.add_argument("--ttl", type=int, default=1800, help="Token lifetime in seconds")
252
+ p_run.add_argument("script_args", nargs="*", help="Arguments passed to the robot")
253
+ p_run.set_defaults(func=cmd_dev_run)
254
+
255
+ p_env = dev_sub.add_parser(
256
+ "env", help="Print/write env vars to run+debug a robot from your IDE (breakpoints)"
257
+ )
258
+ p_env.add_argument("--environment", "-e", default="dev", choices=["dev", "staging"])
259
+ p_env.add_argument("--assets", help="Comma-separated asset names to scope the token to")
260
+ # Default 8h so a debug session with breakpoints doesn't expire mid-pause.
261
+ p_env.add_argument("--ttl", type=int, default=28800, help="Token lifetime in seconds")
262
+ p_env.add_argument("--format", choices=["dotenv", "bash", "powershell"], default="dotenv")
263
+ p_env.add_argument("--write", metavar="PATH", help="Write to a file (e.g. .env) instead of stdout")
264
+ p_env.set_defaults(func=cmd_dev_env)
265
+
266
+ return parser
267
+
268
+
269
+ def main() -> None:
270
+ parser = build_parser()
271
+ args = parser.parse_args()
272
+ sys.exit(args.func(args))
273
+
274
+
275
+ if __name__ == "__main__":
276
+ main()
@@ -0,0 +1,285 @@
1
+ """NORA Agent SDK — helpers for bot scripts to interact with NORA API.
2
+
3
+ Security notes:
4
+ - The access token is only ever sent over HTTPS (plain http is refused except
5
+ for localhost), so a misconfigured/poisoned NORA_API_URL cannot exfiltrate it.
6
+ - Every value placed in a URL path (asset/queue/item names, job id) is
7
+ percent-encoded, so a crafted name cannot inject extra path or query segments.
8
+ - No eval/exec and no shell are used anywhere in this module.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from typing import Any
15
+ from urllib.parse import quote, urlparse
16
+
17
+ import httpx
18
+
19
+ _LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
20
+
21
+
22
+ def _enforce_https(api_url: str) -> str:
23
+ """Refuse to send the bearer token over an unencrypted connection.
24
+
25
+ https is required for every host except localhost (local development).
26
+ """
27
+ parsed = urlparse(api_url)
28
+ host = (parsed.hostname or "").lower()
29
+ if parsed.scheme != "https" and host not in _LOCAL_HOSTS:
30
+ raise RuntimeError(
31
+ f"NORA_API_URL must use https (got scheme '{parsed.scheme}' for host "
32
+ f"'{host}'). Refusing to send the access token over plain http."
33
+ )
34
+ return api_url
35
+
36
+
37
+ def _seg(value: Any) -> str:
38
+ """Percent-encode a single URL path segment (no '/' passes through).
39
+
40
+ Prevents a crafted name (e.g. '../', 'a/b', '?x=1') from injecting extra
41
+ path or query components into the request URL.
42
+ """
43
+ return quote(str(value), safe="")
44
+
45
+
46
+ def _client() -> tuple[httpx.Client, str]:
47
+ api_url = _enforce_https(os.environ.get("NORA_API_URL", "http://localhost:8000/api/v1"))
48
+ # Prefer the per-job exec token; fall back to NORA_MACHINE_TOKEN for agents
49
+ # that predate exec tokens (the agent sets both to the same value).
50
+ token = os.environ.get("NORA_EXEC_TOKEN") or os.environ.get("NORA_MACHINE_TOKEN", "")
51
+ # verify=True (httpx default) — TLS certificate validation stays on.
52
+ client = httpx.Client(
53
+ timeout=15.0,
54
+ headers={"Authorization": f"Bearer {token}"},
55
+ )
56
+ return client, api_url
57
+
58
+
59
+ def get_job_id() -> str | None:
60
+ """Return the ID of the job that launched this bot, or None when running
61
+ outside the agent (e.g. local dev). Injected by the agent as NORA_JOB_ID."""
62
+ return os.environ.get("NORA_JOB_ID") or None
63
+
64
+
65
+ def get_asset(name: str, environment: str = "production") -> dict[str, Any]:
66
+ """Fetch a decrypted asset by name. Returns {name, type, environment, value, username?}.
67
+
68
+ When the process declares an asset manifest, the agent pre-fetches those
69
+ assets and injects them as NORA_ASSETS — we serve them from there without a
70
+ network call. Anything else falls back to the scoped runtime API.
71
+ """
72
+ injected = os.environ.get("NORA_ASSETS")
73
+ if injected:
74
+ import json
75
+ try:
76
+ cached = json.loads(injected)
77
+ except ValueError:
78
+ cached = {}
79
+ if name in cached:
80
+ return cached[name]
81
+
82
+ client, api_url = _client()
83
+ try:
84
+ resp = client.get(
85
+ f"{api_url}/agent/assets/{_seg(name)}",
86
+ params={"environment": environment},
87
+ )
88
+ resp.raise_for_status()
89
+ return resp.json()["data"]
90
+ finally:
91
+ client.close()
92
+
93
+
94
+ def get_queue_item(queue_name: str) -> dict[str, Any] | None:
95
+ """Claim the next available item from a queue. Returns item data or None."""
96
+ client, api_url = _client()
97
+ try:
98
+ resp = client.post(f"{api_url}/agent/queues/{_seg(queue_name)}/next")
99
+ resp.raise_for_status()
100
+ return resp.json()["data"]
101
+ finally:
102
+ client.close()
103
+
104
+
105
+ def complete_queue_item(queue_name: str, item_id: str, result: dict[str, Any]) -> None:
106
+ """Mark a queue item as completed with result data."""
107
+ client, api_url = _client()
108
+ try:
109
+ resp = client.put(
110
+ f"{api_url}/agent/queues/{_seg(queue_name)}/items/{_seg(item_id)}/complete",
111
+ json={"result": result},
112
+ )
113
+ resp.raise_for_status()
114
+ finally:
115
+ client.close()
116
+
117
+
118
+ def fail_queue_item(queue_name: str, item_id: str, error_message: str) -> None:
119
+ """Mark a queue item as failed (may retry based on queue max_retries)."""
120
+ client, api_url = _client()
121
+ try:
122
+ resp = client.put(
123
+ f"{api_url}/agent/queues/{_seg(queue_name)}/items/{_seg(item_id)}/fail",
124
+ json={"error_message": error_message},
125
+ )
126
+ resp.raise_for_status()
127
+ finally:
128
+ client.close()
129
+
130
+
131
+ def check_for_update(current_version: str) -> dict[str, Any]:
132
+ """Check if a newer agent version is available.
133
+
134
+ Returns:
135
+ dict with keys: update_available, latest_version, is_mandatory,
136
+ changelog, download_url_macos, download_url_windows.
137
+ """
138
+ client, api_url = _client()
139
+ try:
140
+ resp = client.get(
141
+ f"{api_url}/agent/version/latest",
142
+ params={"current": current_version},
143
+ )
144
+ resp.raise_for_status()
145
+ return resp.json()["data"]
146
+ finally:
147
+ client.close()
148
+
149
+
150
+ def log(level: str, message: str, data: dict[str, Any] | None = None) -> None:
151
+ """Append a structured log line to the current job's log stream.
152
+
153
+ Requires NORA_JOB_ID (injected automatically by the agent).
154
+ """
155
+ import json
156
+ import time
157
+
158
+ job_id = os.environ.get("NORA_JOB_ID")
159
+ if not job_id:
160
+ return # No-op outside of a job context
161
+
162
+ ts = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
163
+ line = f"[{ts}] [{level.upper()}] {message}"
164
+ if data:
165
+ line += f" | {json.dumps(data, ensure_ascii=False)}"
166
+
167
+ client, api_url = _client()
168
+ try:
169
+ resp = client.patch(
170
+ f"{api_url}/agent/jobs/{_seg(job_id)}/logs",
171
+ json={"logs": line + "\n"},
172
+ )
173
+ resp.raise_for_status()
174
+ finally:
175
+ client.close()
176
+
177
+
178
+ def update_progress(percent: int, message: str | None = None) -> None:
179
+ """Report execution progress to the NORA dashboard.
180
+
181
+ Requires NORA_JOB_ID (injected automatically by the agent).
182
+ """
183
+ job_id = os.environ.get("NORA_JOB_ID")
184
+ if not job_id:
185
+ return # No-op outside of a job context
186
+
187
+ client, api_url = _client()
188
+ try:
189
+ resp = client.patch(
190
+ f"{api_url}/agent/jobs/{_seg(job_id)}/progress",
191
+ json={"percent": max(0, min(100, percent)), "message": message},
192
+ )
193
+ resp.raise_for_status()
194
+ finally:
195
+ client.close()
196
+
197
+
198
+ def add_queue_items(
199
+ queue_name: str,
200
+ items: list[dict[str, Any]],
201
+ priority: int = 3,
202
+ ) -> int:
203
+ """Add items to a queue from within a bot script.
204
+
205
+ Args:
206
+ queue_name: The name of the target queue.
207
+ items: List of dicts — each dict is the payload for one item.
208
+ priority: 1=low, 3=normal, 5=urgent (applied to all items).
209
+
210
+ Returns:
211
+ Number of items successfully added.
212
+ """
213
+ client, api_url = _client()
214
+ try:
215
+ resp = client.post(
216
+ f"{api_url}/agent/queues/{_seg(queue_name)}/items/bulk",
217
+ json={"items": items, "priority": priority},
218
+ )
219
+ resp.raise_for_status()
220
+ return resp.json()["data"]["added"]
221
+ finally:
222
+ client.close()
223
+
224
+
225
+ def send_queue_item_for_review(queue_name: str, item_id: str) -> dict[str, Any]:
226
+ """Mark a queue item as pending human review.
227
+
228
+ After calling this, use wait_for_queue_review() to block until the
229
+ operator approves or rejects the item.
230
+ """
231
+ client, api_url = _client()
232
+ try:
233
+ resp = client.put(
234
+ f"{api_url}/agent/queues/{_seg(queue_name)}/items/{_seg(item_id)}/review"
235
+ )
236
+ resp.raise_for_status()
237
+ return resp.json()["data"]
238
+ finally:
239
+ client.close()
240
+
241
+
242
+ def wait_for_queue_review(
243
+ queue_name: str,
244
+ item_id: str,
245
+ poll_interval: float = 5.0,
246
+ timeout: float = 3600.0,
247
+ ) -> str:
248
+ """Block execution until an operator approves or rejects the item.
249
+
250
+ Returns:
251
+ "approved" — item status changed to "new" (continue processing).
252
+ "rejected" — item status changed to "failed" (skip this item).
253
+
254
+ Raises:
255
+ TimeoutError: if timeout seconds elapse without a decision.
256
+ RuntimeError: if the item is in an unexpected state.
257
+ """
258
+ import time
259
+
260
+ deadline = time.monotonic() + timeout
261
+ while time.monotonic() < deadline:
262
+ client, api_url = _client()
263
+ try:
264
+ resp = client.get(
265
+ f"{api_url}/agent/queues/{_seg(queue_name)}/items/{_seg(item_id)}"
266
+ )
267
+ resp.raise_for_status()
268
+ item = resp.json()["data"]
269
+ finally:
270
+ client.close()
271
+
272
+ status = item.get("status")
273
+ if status == "new":
274
+ return "approved"
275
+ if status == "failed":
276
+ return "rejected"
277
+ if status != "pending_review":
278
+ raise RuntimeError(
279
+ f"Unexpected item status '{status}' for item {item_id}"
280
+ )
281
+ time.sleep(poll_interval)
282
+
283
+ raise TimeoutError(
284
+ f"Item {item_id} was not reviewed within {timeout} seconds"
285
+ )
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.4
2
+ Name: nora-sdk
3
+ Version: 0.4.0
4
+ Summary: Client SDK + dev CLI to build and debug robots on NORA (Robots Center).
5
+ Author: Valisoft
6
+ License: Proprietary
7
+ Project-URL: Homepage, https://nora.valisoftconsulting.com
8
+ Keywords: nora,rpa,automation,sdk,robots
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Operating System :: Microsoft :: Windows
11
+ Classifier: Operating System :: MacOS
12
+ Classifier: License :: Other/Proprietary License
13
+ Classifier: Intended Audience :: Developers
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: httpx<1.0,>=0.27
18
+ Dynamic: license-file
19
+
20
+ # nora-sdk
21
+
22
+ Client SDK + `nora` developer CLI to build and debug **robots** on NORA
23
+ (Robots Center). Install it to develop locally; in production the NORA agent
24
+ provides the same SDK automatically, so your robot code is identical.
25
+
26
+ ```bash
27
+ pip install nora-sdk
28
+ ```
29
+
30
+ ## Uso en el robot
31
+ ```python
32
+ from nora_agent import sdk
33
+
34
+ item = sdk.get_queue_item("RPA-Challenge") # consume una cola
35
+ cred = sdk.get_asset("API_KEY") # lee un asset (secreto)
36
+ sdk.log("info", "hola") # log al dashboard
37
+ sdk.update_progress(50, "a mitad")
38
+ ```
39
+
40
+ ## Desarrollo local con breakpoints
41
+ ```bash
42
+ nora login
43
+ nora dev env --write .env # NORA_API_URL + token de dev (dev/staging, no prod)
44
+ # apunta tu IDE a .env y debuggea, o:
45
+ nora dev run main.py
46
+ ```
47
+
48
+ ## Seguridad
49
+ - El token solo viaja por **HTTPS** (rechaza http salvo localhost).
50
+ - Los nombres en la URL van **percent-encoded** (sin inyección de path/query).
51
+ - Sin `shell`, sin `eval`/`exec`. Dependencia única: `httpx`.
52
+ - El token de dev está limitado a `dev`/`staging`: nunca lee secretos de producción.
53
+ - La sesión se guarda en `~/.nora/credentials.json` con permisos solo-dueño.
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ nora_agent/__init__.py
5
+ nora_agent/dev_cli.py
6
+ nora_agent/sdk.py
7
+ nora_sdk.egg-info/PKG-INFO
8
+ nora_sdk.egg-info/SOURCES.txt
9
+ nora_sdk.egg-info/dependency_links.txt
10
+ nora_sdk.egg-info/entry_points.txt
11
+ nora_sdk.egg-info/requires.txt
12
+ nora_sdk.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nora = nora_agent.dev_cli:main
@@ -0,0 +1 @@
1
+ httpx<1.0,>=0.27
@@ -0,0 +1 @@
1
+ nora_agent
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "nora-sdk"
7
+ description = "Client SDK + dev CLI to build and debug robots on NORA (Robots Center)."
8
+ readme = "README.md"
9
+ requires-python = ">=3.10"
10
+ license = { text = "Proprietary" }
11
+ authors = [{ name = "Valisoft" }]
12
+ keywords = ["nora", "rpa", "automation", "sdk", "robots"]
13
+ dynamic = ["version"]
14
+
15
+ # Minimal, pinned dependency surface on purpose (smaller supply-chain attack
16
+ # surface). The SDK and the `nora` CLI need only httpx.
17
+ dependencies = ["httpx>=0.27,<1.0"]
18
+
19
+ classifiers = [
20
+ "Programming Language :: Python :: 3",
21
+ "Operating System :: Microsoft :: Windows",
22
+ "Operating System :: MacOS",
23
+ "License :: Other/Proprietary License",
24
+ "Intended Audience :: Developers",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://nora.valisoftconsulting.com"
29
+
30
+ [project.scripts]
31
+ nora = "nora_agent.dev_cli:main"
32
+
33
+ [tool.setuptools]
34
+ # The package contents are assembled from agent/nora_agent by sdk/assemble.py
35
+ # (single source of truth) — only the client-facing modules, never the agent
36
+ # runtime/installer/service code.
37
+ packages = ["nora_agent"]
38
+
39
+ [tool.setuptools.dynamic]
40
+ version = { attr = "nora_agent.__version__" }
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+