pulse-ai-cli 0.1.0__tar.gz → 0.2.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.
- {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/PKG-INFO +7 -4
- {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/README.md +4 -1
- {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/docs.md +35 -11
- pulse_ai_cli-0.2.0/pulse_cli/__init__.py +3 -0
- {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/app.py +5 -1
- {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/client.py +49 -15
- pulse_ai_cli-0.2.0/pulse_cli/commands/auth.py +214 -0
- pulse_ai_cli-0.2.0/pulse_cli/commands/charts.py +116 -0
- {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/commands/extract.py +89 -36
- {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/commands/jobs.py +15 -8
- {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/commands/open_platform.py +2 -2
- {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/commands/schema.py +36 -22
- {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/commands/split.py +13 -7
- {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/commands/tables.py +10 -6
- {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/commands/usage.py +16 -12
- pulse_ai_cli-0.2.0/pulse_cli/config.py +220 -0
- {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/errors.py +11 -12
- pulse_ai_cli-0.2.0/pulse_cli/oauth.py +272 -0
- {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/output.py +9 -4
- {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/repl.py +6 -3
- {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/runtime.py +11 -12
- pulse_ai_cli-0.2.0/pulse_cli/status.py +15 -0
- pulse_ai_cli-0.2.0/pulse_cli/templates/callback.html +117 -0
- {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/tui/dashboard.py +12 -3
- {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/tui/logo.py +14 -4
- {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pyproject.toml +3 -2
- pulse_ai_cli-0.2.0/tests/test_auth_flow.py +444 -0
- pulse_ai_cli-0.2.0/tests/test_charts_command.py +148 -0
- pulse_ai_cli-0.2.0/tests/test_extract_command.py +20 -0
- pulse_ai_cli-0.2.0/tests/test_oauth_login.py +166 -0
- {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/tests/test_smoke.py +43 -1
- pulse_ai_cli-0.1.0/pulse_cli/__init__.py +0 -3
- pulse_ai_cli-0.1.0/pulse_cli/commands/auth.py +0 -114
- pulse_ai_cli-0.1.0/pulse_cli/config.py +0 -141
- pulse_ai_cli-0.1.0/pulse_cli/oauth.py +0 -95
- pulse_ai_cli-0.1.0/pulse_cli/status.py +0 -10
- {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/.gitignore +0 -0
- {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/__main__.py +0 -0
- {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/commands/__init__.py +0 -0
- {pulse_ai_cli-0.1.0 → pulse_ai_cli-0.2.0}/pulse_cli/tui/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: pulse-ai-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Pulse CLI — document extraction from your terminal (runpulse.com)
|
|
5
5
|
Project-URL: Homepage, https://runpulse.com
|
|
6
6
|
Project-URL: Documentation, https://docs.runpulse.com
|
|
@@ -14,6 +14,7 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
14
14
|
Requires-Python: >=3.11
|
|
15
15
|
Requires-Dist: click>=8
|
|
16
16
|
Requires-Dist: httpx>=0.27
|
|
17
|
+
Requires-Dist: keyring>=24
|
|
17
18
|
Requires-Dist: rich>=13.7
|
|
18
19
|
Requires-Dist: textual>=0.80
|
|
19
20
|
Requires-Dist: typer>=0.12
|
|
@@ -21,13 +22,13 @@ Provides-Extra: dev
|
|
|
21
22
|
Requires-Dist: pytest>=8; extra == 'dev'
|
|
22
23
|
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
23
24
|
Provides-Extra: keyring
|
|
24
|
-
Requires-Dist: keyring>=24; extra == 'keyring'
|
|
25
25
|
Description-Content-Type: text/markdown
|
|
26
26
|
|
|
27
27
|
# Pulse CLI
|
|
28
28
|
|
|
29
29
|
Document extraction from your terminal — markdown, structured JSON schemas,
|
|
30
|
-
splits, and
|
|
30
|
+
splits, tables, and chart reconstruction, powered by the
|
|
31
|
+
[Pulse](https://runpulse.com) API.
|
|
31
32
|
|
|
32
33
|
```
|
|
33
34
|
uv tool install pulse-ai-cli
|
|
@@ -40,6 +41,8 @@ pulse extract invoice.pdf --schema schemas/invoice.json
|
|
|
40
41
|
- `pulse schema generate / apply` — AI-draft a JSON Schema, run it against
|
|
41
42
|
any extraction
|
|
42
43
|
- `pulse split` / `pulse tables` — topic-based splitting, table extraction
|
|
44
|
+
- `pulse charts` — reconstruct chart series and export Excel, CSV, or LAS data;
|
|
45
|
+
add `--no-wait` to submit an async chart job without polling
|
|
43
46
|
- `pulse jobs` — watch, resume, or cancel async jobs
|
|
44
47
|
- `pulse usage` — plan and page/credit usage
|
|
45
48
|
- bare `pulse` — interactive shell with the full command set
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# Pulse CLI
|
|
2
2
|
|
|
3
3
|
Document extraction from your terminal — markdown, structured JSON schemas,
|
|
4
|
-
splits, and
|
|
4
|
+
splits, tables, and chart reconstruction, powered by the
|
|
5
|
+
[Pulse](https://runpulse.com) API.
|
|
5
6
|
|
|
6
7
|
```
|
|
7
8
|
uv tool install pulse-ai-cli
|
|
@@ -14,6 +15,8 @@ pulse extract invoice.pdf --schema schemas/invoice.json
|
|
|
14
15
|
- `pulse schema generate / apply` — AI-draft a JSON Schema, run it against
|
|
15
16
|
any extraction
|
|
16
17
|
- `pulse split` / `pulse tables` — topic-based splitting, table extraction
|
|
18
|
+
- `pulse charts` — reconstruct chart series and export Excel, CSV, or LAS data;
|
|
19
|
+
add `--no-wait` to submit an async chart job without polling
|
|
17
20
|
- `pulse jobs` — watch, resume, or cancel async jobs
|
|
18
21
|
- `pulse usage` — plan and page/credit usage
|
|
19
22
|
- bare `pulse` — interactive shell with the full command set
|
|
@@ -23,15 +23,27 @@ Python 3.11+.
|
|
|
23
23
|
pulse login
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
Opens the Pulse platform in your browser; you approve
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
keychain
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
26
|
+
Opens the Pulse platform in your browser; you approve the request while
|
|
27
|
+
signed in, and the CLI creates an API key for this device (OAuth 2.1
|
|
28
|
+
authorization code + PKCE on a loopback redirect, RFC 8252). The key shows up
|
|
29
|
+
under API Keys as `pulse-cli <hostname>`, is validated, and is stored in your
|
|
30
|
+
OS keychain (or `~/.pulse/config.toml`, chmod 0600, if no keychain is
|
|
31
|
+
available). `pulse logout` deletes it again, as does a later `pulse login`
|
|
32
|
+
that replaces it or a login that fails after the key was created; a key you
|
|
33
|
+
pasted yourself is only forgotten.
|
|
34
|
+
|
|
35
|
+
This key is this machine's login, not a credential for automation: it expires
|
|
36
|
+
after 90 days (`pulse whoami` shows when), and then `pulse login` makes a new
|
|
37
|
+
one. For CI, cron jobs, servers or an MCP client, create a key in the platform
|
|
38
|
+
under API Keys and pass it as `PULSE_API_KEY`.
|
|
39
|
+
|
|
40
|
+
No browser on this machine (SSH, containers)? `pulse login --device` prints a
|
|
41
|
+
URL and a short code (e.g. `ABCD-1234`) to type in a browser on any device
|
|
42
|
+
(OAuth 2.0 device flow, RFC 8628). Only do this for a login you started
|
|
43
|
+
yourself: never enter a code someone sent you.
|
|
44
|
+
|
|
45
|
+
`pulse login --api-key <key>` (or `pulse login --no-web` to be prompted) logs
|
|
46
|
+
in with an API key directly — find yours in the Pulse platform under API Keys.
|
|
35
47
|
|
|
36
48
|
Alternatives, in priority order:
|
|
37
49
|
|
|
@@ -141,6 +153,20 @@ pulse tables <extraction_id> --format html
|
|
|
141
153
|
`--format html|json`, `--merge` (join tables spanning pages),
|
|
142
154
|
`--charts-to-tables`, `-o <file>`.
|
|
143
155
|
|
|
156
|
+
### `pulse charts [<extraction_id>]`
|
|
157
|
+
|
|
158
|
+
Reconstruct chart series from a completed extraction or split:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
pulse charts <extraction_id> --data-points 200 --agentic-zoom \
|
|
162
|
+
--export excel --export csv
|
|
163
|
+
pulse charts --split-id <split_id> --export las
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Use `--pages "1-3,5"` with an extraction. Split results inherit their pages and
|
|
167
|
+
cannot use `--pages`. `--download-exports <directory>` explicitly consumes the
|
|
168
|
+
authenticated one-time export links; without it the CLI returns the links untouched.
|
|
169
|
+
|
|
144
170
|
### `pulse jobs`
|
|
145
171
|
|
|
146
172
|
Every long-running command runs async under the hood and polls for you.
|
|
@@ -196,6 +222,4 @@ the interactive shell.
|
|
|
196
222
|
|
|
197
223
|
- `extract --split … --schema …` end-to-end pipeline (split a document, then apply
|
|
198
224
|
a schema per topic in one command).
|
|
199
|
-
- Platform `/activate` approval page for `pulse login --web` (the API-side
|
|
200
|
-
device flow is live; approval UI is rolling out).
|
|
201
225
|
- Full OAuth token auth for the MCP server (auth-code + PKCE, DCR).
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
"""Pulse CLI root app."""
|
|
2
|
+
|
|
2
3
|
from __future__ import annotations
|
|
3
4
|
|
|
4
5
|
import typer
|
|
5
6
|
|
|
6
7
|
from pulse_cli import __version__
|
|
7
8
|
from pulse_cli.commands.auth import login, logout, whoami
|
|
9
|
+
from pulse_cli.commands.charts import charts
|
|
8
10
|
from pulse_cli.commands.extract import extract
|
|
9
11
|
from pulse_cli.commands.jobs import jobs_app
|
|
10
12
|
from pulse_cli.commands.open_platform import open_platform
|
|
@@ -25,6 +27,7 @@ _EXAMPLES = [
|
|
|
25
27
|
("pulse schema apply <extraction_id> --schema s.json", "structured fields from a past extract"),
|
|
26
28
|
('pulse split <extraction_id> -t "intro:first part"', "split a document by topic"),
|
|
27
29
|
("pulse tables <extraction_id>", "pull out tables"),
|
|
30
|
+
("pulse charts <extraction_id>", "reconstruct charts and export their data"),
|
|
28
31
|
("pulse jobs get <job_id> --watch", "watch or resume an async job"),
|
|
29
32
|
("pulse open <extraction_id>", "view an extraction on the platform"),
|
|
30
33
|
("pulse usage", "plan + credit/page usage"),
|
|
@@ -78,6 +81,7 @@ cli.command()(whoami)
|
|
|
78
81
|
cli.command()(extract)
|
|
79
82
|
cli.command()(split)
|
|
80
83
|
cli.command()(tables)
|
|
84
|
+
cli.command()(charts)
|
|
81
85
|
cli.command()(usage)
|
|
82
86
|
cli.command("open")(open_platform)
|
|
83
87
|
cli.add_typer(schema_app, name="schema")
|
|
@@ -96,7 +100,7 @@ def app() -> None:
|
|
|
96
100
|
cli()
|
|
97
101
|
except PulseCliError as exc:
|
|
98
102
|
err_console.print(f"[err]error:[/err] {exc}")
|
|
99
|
-
raise SystemExit(1)
|
|
103
|
+
raise SystemExit(1) from exc
|
|
100
104
|
|
|
101
105
|
|
|
102
106
|
if __name__ == "__main__":
|
|
@@ -1,14 +1,17 @@
|
|
|
1
|
-
"""Sync HTTP client for the Pulse engine — a CLI port of
|
|
1
|
+
"""Sync HTTP client for the Pulse engine — a CLI port of pulse_mcp/pulse_client.py.
|
|
2
2
|
|
|
3
3
|
Same endpoints, same payload shapes, same submit -> poll semantics, so the CLI
|
|
4
|
-
and MCP gateway behave identically against the engine. Auth is the caller's API
|
|
4
|
+
and MCP gateway behave identically against the v1.engine. Auth is the caller's API
|
|
5
5
|
key forwarded verbatim as ``x-api-key``; the engine enforces auth/credits/limits.
|
|
6
6
|
"""
|
|
7
|
+
|
|
7
8
|
from __future__ import annotations
|
|
8
9
|
|
|
9
10
|
import json
|
|
10
11
|
import time
|
|
11
|
-
from
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import urljoin, urlsplit
|
|
12
15
|
|
|
13
16
|
import httpx
|
|
14
17
|
|
|
@@ -35,7 +38,9 @@ def _form_value(v: Any) -> str:
|
|
|
35
38
|
|
|
36
39
|
|
|
37
40
|
class PulseClient:
|
|
38
|
-
def __init__(
|
|
41
|
+
def __init__(
|
|
42
|
+
self, api_key: str, base_url: str, *, transport: httpx.BaseTransport | None = None
|
|
43
|
+
) -> None:
|
|
39
44
|
self._client = httpx.Client(
|
|
40
45
|
base_url=base_url.rstrip("/"),
|
|
41
46
|
timeout=DEFAULT_TIMEOUT,
|
|
@@ -43,7 +48,7 @@ class PulseClient:
|
|
|
43
48
|
transport=transport,
|
|
44
49
|
)
|
|
45
50
|
|
|
46
|
-
def __enter__(self) ->
|
|
51
|
+
def __enter__(self) -> PulseClient:
|
|
47
52
|
return self
|
|
48
53
|
|
|
49
54
|
def __exit__(self, *exc: Any) -> None:
|
|
@@ -55,7 +60,9 @@ class PulseClient:
|
|
|
55
60
|
try:
|
|
56
61
|
resp = self._client.request(method, path, **kwargs)
|
|
57
62
|
except httpx.TimeoutException as exc:
|
|
58
|
-
raise PulseEngineError(
|
|
63
|
+
raise PulseEngineError(
|
|
64
|
+
504, engine_message="The Pulse engine took too long to respond."
|
|
65
|
+
) from exc
|
|
59
66
|
except httpx.RequestError as exc:
|
|
60
67
|
raise PulseEngineError(
|
|
61
68
|
502, engine_message=f"Could not reach the Pulse engine at {self._client.base_url}."
|
|
@@ -97,7 +104,29 @@ class PulseClient:
|
|
|
97
104
|
def tables(self, payload: dict) -> dict:
|
|
98
105
|
return self._request("POST", "/tables", json=payload)
|
|
99
106
|
|
|
100
|
-
def
|
|
107
|
+
def charts(self, payload: dict) -> dict:
|
|
108
|
+
return self._request("POST", "/charts", json=payload)
|
|
109
|
+
|
|
110
|
+
def download_artifact(self, url: str) -> bytes:
|
|
111
|
+
"""Download one chart artifact, authenticating only to the Pulse origin."""
|
|
112
|
+
resolved = urljoin(str(self._client.base_url), url)
|
|
113
|
+
api_origin = urlsplit(str(self._client.base_url))[:2]
|
|
114
|
+
artifact_origin = urlsplit(resolved)[:2]
|
|
115
|
+
if artifact_origin == api_origin:
|
|
116
|
+
response = self._client.get(resolved, follow_redirects=False)
|
|
117
|
+
else:
|
|
118
|
+
response = httpx.get(resolved, timeout=DEFAULT_TIMEOUT, follow_redirects=True)
|
|
119
|
+
if not response.is_success:
|
|
120
|
+
try:
|
|
121
|
+
body: Any = response.json()
|
|
122
|
+
except Exception:
|
|
123
|
+
body = response.text
|
|
124
|
+
raise PulseEngineError.from_response(response.status_code, body)
|
|
125
|
+
return response.content
|
|
126
|
+
|
|
127
|
+
def generate_schema(
|
|
128
|
+
self, *, prompt: str | None = None, current_schema: str | None = None
|
|
129
|
+
) -> dict:
|
|
101
130
|
data: dict[str, str] = {}
|
|
102
131
|
if prompt is not None:
|
|
103
132
|
data["prompt"] = prompt
|
|
@@ -116,7 +145,7 @@ class PulseClient:
|
|
|
116
145
|
job_id: str,
|
|
117
146
|
*,
|
|
118
147
|
interval_seconds: float = POLL_INTERVAL_SECONDS,
|
|
119
|
-
on_progress:
|
|
148
|
+
on_progress: ProgressCallback | None = None,
|
|
120
149
|
) -> dict:
|
|
121
150
|
"""Poll GET /job/{job_id} until terminal. The CLI polls indefinitely
|
|
122
151
|
(unlike MCP's inline budget) — Ctrl-C leaves the job running server-side."""
|
|
@@ -134,7 +163,9 @@ class PulseClient:
|
|
|
134
163
|
"""Download an offloaded {is_url, url} result (pre-signed S3)."""
|
|
135
164
|
resp = httpx.get(url, timeout=DEFAULT_TIMEOUT, follow_redirects=True)
|
|
136
165
|
if not resp.is_success:
|
|
137
|
-
raise PulseEngineError(
|
|
166
|
+
raise PulseEngineError(
|
|
167
|
+
resp.status_code, engine_message="Failed to download result from URL."
|
|
168
|
+
)
|
|
138
169
|
if "application/json" in resp.headers.get("content-type", ""):
|
|
139
170
|
return resp.json()
|
|
140
171
|
try:
|
|
@@ -153,20 +184,23 @@ class PlatformClient:
|
|
|
153
184
|
headers={"x-api-key": api_key, "user-agent": f"pulse-cli/{__version__}"},
|
|
154
185
|
)
|
|
155
186
|
|
|
156
|
-
def __enter__(self) ->
|
|
187
|
+
def __enter__(self) -> PlatformClient:
|
|
157
188
|
return self
|
|
158
189
|
|
|
159
190
|
def __exit__(self, *exc: Any) -> None:
|
|
160
191
|
self._client.close()
|
|
161
192
|
|
|
162
|
-
def _get(self, path: str, params:
|
|
193
|
+
def _get(self, path: str, params: dict | None = None) -> dict:
|
|
163
194
|
try:
|
|
164
195
|
resp = self._client.get(path, params=params)
|
|
165
196
|
except httpx.TimeoutException as exc:
|
|
166
|
-
raise PulseEngineError(
|
|
197
|
+
raise PulseEngineError(
|
|
198
|
+
504, engine_message="The Pulse platform took too long to respond."
|
|
199
|
+
) from exc
|
|
167
200
|
except httpx.RequestError as exc:
|
|
168
201
|
raise PulseEngineError(
|
|
169
|
-
502,
|
|
202
|
+
502,
|
|
203
|
+
engine_message=f"Could not reach the Pulse platform at {self._client.base_url}.",
|
|
170
204
|
) from exc
|
|
171
205
|
if resp.is_success:
|
|
172
206
|
return resp.json()
|
|
@@ -176,7 +210,7 @@ class PlatformClient:
|
|
|
176
210
|
body = resp.text
|
|
177
211
|
raise PulseEngineError.from_response(resp.status_code, body)
|
|
178
212
|
|
|
179
|
-
def subscription(self) ->
|
|
213
|
+
def subscription(self) -> dict | None:
|
|
180
214
|
"""GET /api/v1/billing/subscription — plan/status; None if unreachable."""
|
|
181
215
|
try:
|
|
182
216
|
return self._get("/api/v1/billing/subscription")
|
|
@@ -187,7 +221,7 @@ class PlatformClient:
|
|
|
187
221
|
"""GET /api/v1/analytics/usage/current-period — plan + pages for the billing cycle."""
|
|
188
222
|
return self._get("/api/v1/analytics/usage/current-period")
|
|
189
223
|
|
|
190
|
-
def usage_breakdown(self, start_date:
|
|
224
|
+
def usage_breakdown(self, start_date: str | None = None, end_date: str | None = None) -> dict:
|
|
191
225
|
"""GET /api/v1/analytics/usage/breakdown — docs/pages/latency, by source.
|
|
192
226
|
|
|
193
227
|
Without dates the platform defaults to the current billing period
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""`pulse login` / `pulse logout` / `pulse whoami`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import webbrowser
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from pulse_cli import config as cfg
|
|
10
|
+
from pulse_cli import oauth
|
|
11
|
+
from pulse_cli.client import PlatformClient, PulseClient
|
|
12
|
+
from pulse_cli.errors import PulseCliError
|
|
13
|
+
from pulse_cli.output import console, fail, print_json
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def login(
|
|
17
|
+
api_key: str | None = typer.Option(
|
|
18
|
+
None, "--api-key", help="Skip the browser and log in with an API key directly."
|
|
19
|
+
),
|
|
20
|
+
base_url: str | None = typer.Option(
|
|
21
|
+
None, "--base-url", help="Engine URL override (default: api.runpulse.com)."
|
|
22
|
+
),
|
|
23
|
+
platform_url: str | None = typer.Option(
|
|
24
|
+
None, "--platform-url", help="Platform API URL (enables `pulse usage`)."
|
|
25
|
+
),
|
|
26
|
+
web: bool = typer.Option(
|
|
27
|
+
True,
|
|
28
|
+
"--web/--no-web",
|
|
29
|
+
help="Browser login (default). --no-web prompts for an API key instead.",
|
|
30
|
+
),
|
|
31
|
+
device: bool = typer.Option(
|
|
32
|
+
False,
|
|
33
|
+
"--device",
|
|
34
|
+
help="Approve from another device, for machines with no browser (SSH, containers).",
|
|
35
|
+
),
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Log in via the browser (default) or with an API key; the key is validated, then stored securely."""
|
|
38
|
+
try:
|
|
39
|
+
conf = cfg.load(api_url_override=base_url)
|
|
40
|
+
cfg.ensure_keychain_reachable()
|
|
41
|
+
previous, previous_on = cfg.stored_api_key()
|
|
42
|
+
except PulseCliError as exc:
|
|
43
|
+
raise fail(str(exc)) from exc
|
|
44
|
+
except Exception as exc: # a locked keychain or a denied keychain prompt
|
|
45
|
+
raise _keychain_failure("login", exc) from exc
|
|
46
|
+
purl = platform_url or conf.platform_url
|
|
47
|
+
minted: oauth.MintedKey | None = None
|
|
48
|
+
if api_key:
|
|
49
|
+
key = api_key
|
|
50
|
+
elif device or web:
|
|
51
|
+
minted = _device_flow_login(purl) if device else _browser_login(purl)
|
|
52
|
+
key = minted.api_key
|
|
53
|
+
else:
|
|
54
|
+
key = typer.prompt("Pulse API key", hide_input=True)
|
|
55
|
+
try:
|
|
56
|
+
key = key.strip()
|
|
57
|
+
if not key:
|
|
58
|
+
raise fail("empty API key")
|
|
59
|
+
console.print(f"[muted]Validating key against {conf.api_url} …[/muted]")
|
|
60
|
+
try:
|
|
61
|
+
with PulseClient(key, conf.api_url) as client:
|
|
62
|
+
pages = client.pages()
|
|
63
|
+
except PulseCliError as exc:
|
|
64
|
+
raise fail(str(exc)) from exc
|
|
65
|
+
minted_on = purl if minted else (previous_on if key == previous else None)
|
|
66
|
+
try:
|
|
67
|
+
store = cfg.save_api_key(
|
|
68
|
+
key, minted_on=minted_on, expires_at=minted.expires_at if minted else None
|
|
69
|
+
)
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
raise fail(f"could not save the API key ({exc}); the stored key is unchanged.") from exc
|
|
72
|
+
except BaseException:
|
|
73
|
+
# Nothing holds a key minted for a login that did not finish.
|
|
74
|
+
if minted:
|
|
75
|
+
_delete_device_key(purl, key)
|
|
76
|
+
raise
|
|
77
|
+
# The device key this login replaces is held by nothing else.
|
|
78
|
+
if previous and previous_on and previous != key:
|
|
79
|
+
_delete_device_key(previous_on, previous)
|
|
80
|
+
if base_url or platform_url:
|
|
81
|
+
cfg.save_settings(api_url=base_url, platform_url=platform_url)
|
|
82
|
+
|
|
83
|
+
where = "OS keychain" if store == "keyring" else f"{cfg.CONFIG_PATH} (0600)"
|
|
84
|
+
console.print(f"[ok]Logged in.[/ok] Key {cfg.redact(key)} stored in {where}.")
|
|
85
|
+
if store == "file":
|
|
86
|
+
console.print(
|
|
87
|
+
"[yellow]No OS keychain is available here, so the key is stored in plain text, "
|
|
88
|
+
"readable only by you.[/yellow]"
|
|
89
|
+
)
|
|
90
|
+
if minted and minted.expires_at:
|
|
91
|
+
console.print(
|
|
92
|
+
f"[muted]It expires {minted.expires_at}; run `pulse login` again then.[/muted]"
|
|
93
|
+
)
|
|
94
|
+
console.print(f"[muted]Pages processed so far:[/muted] {pages.get('total_pages_processed', 0)}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _browser_login(purl: str) -> oauth.MintedKey:
|
|
98
|
+
"""Approve in this machine's browser, mint this device's own API key."""
|
|
99
|
+
|
|
100
|
+
def open_browser(url: str) -> None:
|
|
101
|
+
console.print("\n Opening your browser. If it doesn't open, visit:")
|
|
102
|
+
# One unbroken line: rich folds a long word with real newlines, which a
|
|
103
|
+
# pasted URL turns into spaces.
|
|
104
|
+
console.print(f" {url}", soft_wrap=True, markup=False, highlight=False)
|
|
105
|
+
console.print(" [muted]No browser on this machine? Use `pulse login --device`.[/muted]\n")
|
|
106
|
+
webbrowser.open(url)
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
with console.status("[muted]Waiting for approval in the browser …[/muted]"):
|
|
110
|
+
tokens = oauth.browser_login(purl, open_browser)
|
|
111
|
+
return oauth.mint_api_key(purl, tokens["access_token"])
|
|
112
|
+
except PulseCliError as exc:
|
|
113
|
+
raise fail(str(exc)) from exc
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _device_flow_login(purl: str) -> oauth.MintedKey:
|
|
117
|
+
"""Device flow: type a code in a browser on any device, mint this device's own API key."""
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
auth = oauth.device_authorization(purl)
|
|
121
|
+
console.print(f"\n Go to: [link]{auth['verification_uri']}[/link]")
|
|
122
|
+
console.print(f" Type this code: [bold]{auth['user_code']}[/bold]\n")
|
|
123
|
+
with console.status("[muted]Waiting for approval in the browser …[/muted]"):
|
|
124
|
+
tokens = oauth.poll_for_tokens(
|
|
125
|
+
purl,
|
|
126
|
+
auth["device_code"],
|
|
127
|
+
interval=int(auth.get("interval", 5)),
|
|
128
|
+
expires_in=int(auth.get("expires_in", 600)),
|
|
129
|
+
)
|
|
130
|
+
return oauth.mint_api_key(purl, tokens["access_token"])
|
|
131
|
+
except PulseCliError as exc:
|
|
132
|
+
raise fail(str(exc)) from exc
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _keychain_failure(command: str, exc: Exception) -> SystemExit:
|
|
136
|
+
return fail(
|
|
137
|
+
f"could not read the OS keychain ({exc}); nothing was changed. Run `pulse {command}` again."
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _delete_device_key(purl: str, key: str) -> bool:
|
|
142
|
+
"""Delete a key `pulse login` minted; a failure only warns."""
|
|
143
|
+
try:
|
|
144
|
+
oauth.delete_api_key(purl, key)
|
|
145
|
+
except PulseCliError as exc:
|
|
146
|
+
console.print(
|
|
147
|
+
f"[yellow]Could not delete this device's API key {cfg.redact(key)} on the platform "
|
|
148
|
+
f"({exc}); it is still active, so remove it under API Keys in the dashboard.[/yellow]"
|
|
149
|
+
)
|
|
150
|
+
return False
|
|
151
|
+
return True
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def logout() -> None:
|
|
155
|
+
"""Remove the stored API key; a key `pulse login` minted is deleted on the platform too.
|
|
156
|
+
|
|
157
|
+
The stored key is the one deleted even when ``PULSE_API_KEY`` overrides it for other commands.
|
|
158
|
+
"""
|
|
159
|
+
try:
|
|
160
|
+
cfg.ensure_keychain_reachable()
|
|
161
|
+
stored, minted_on = cfg.stored_api_key()
|
|
162
|
+
except PulseCliError as exc:
|
|
163
|
+
raise fail(str(exc)) from exc
|
|
164
|
+
except Exception as exc: # a locked keychain or a denied keychain prompt
|
|
165
|
+
raise _keychain_failure("logout", exc) from exc
|
|
166
|
+
deleted = ""
|
|
167
|
+
if stored and minted_on and _delete_device_key(minted_on, stored):
|
|
168
|
+
deleted = " Its API key was deleted on the platform."
|
|
169
|
+
if cfg.clear_api_key():
|
|
170
|
+
console.print(f"[ok]Logged out.[/ok] Stored API key removed.{deleted}")
|
|
171
|
+
elif stored:
|
|
172
|
+
console.print(
|
|
173
|
+
f"[yellow]Could not remove the stored API key from the OS keychain.{deleted} "
|
|
174
|
+
"Run `pulse logout` again.[/yellow]"
|
|
175
|
+
)
|
|
176
|
+
else:
|
|
177
|
+
console.print("[muted]No stored API key found.[/muted]")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def whoami(
|
|
181
|
+
json_out: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
182
|
+
base_url: str | None = typer.Option(None, "--base-url", help="Engine URL override."),
|
|
183
|
+
) -> None:
|
|
184
|
+
"""Show the active key (redacted), where it came from, and account status."""
|
|
185
|
+
conf = cfg.load(api_url_override=base_url)
|
|
186
|
+
if not conf.api_key:
|
|
187
|
+
raise fail("not logged in. Run `pulse login`.")
|
|
188
|
+
|
|
189
|
+
info: dict = {
|
|
190
|
+
"api_key": cfg.redact(conf.api_key),
|
|
191
|
+
"key_source": conf.key_source,
|
|
192
|
+
"expires_at": conf.expires_at
|
|
193
|
+
and f"{conf.expires_at}{' (expired)' if conf.expired else ''}",
|
|
194
|
+
"api_url": conf.api_url,
|
|
195
|
+
}
|
|
196
|
+
try:
|
|
197
|
+
with PulseClient(conf.api_key, conf.api_url) as client:
|
|
198
|
+
info["total_pages_processed"] = client.pages().get("total_pages_processed")
|
|
199
|
+
except PulseCliError as exc:
|
|
200
|
+
info["status"] = f"key check failed: {exc}"
|
|
201
|
+
|
|
202
|
+
if conf.platform_url:
|
|
203
|
+
with PlatformClient(conf.api_key, conf.platform_url) as pc:
|
|
204
|
+
sub = pc.subscription()
|
|
205
|
+
if sub:
|
|
206
|
+
info["plan"] = sub.get("plan") or sub.get("plan_name")
|
|
207
|
+
info["subscription_status"] = sub.get("status")
|
|
208
|
+
|
|
209
|
+
if json_out:
|
|
210
|
+
print_json(info)
|
|
211
|
+
return
|
|
212
|
+
for k, v in info.items():
|
|
213
|
+
if v is not None:
|
|
214
|
+
console.print(f"[muted]{k:24}[/muted] [body]{v}[/body]")
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""`pulse charts` — reconstruct charts from an extraction or split."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from pulse_cli.output import console, fail, print_json
|
|
11
|
+
from pulse_cli.runtime import get_client, resolve_offloaded_result, submit_and_poll
|
|
12
|
+
from pulse_cli.status import is_terminal
|
|
13
|
+
|
|
14
|
+
EXPORT_FORMATS = {"excel", "csv", "las"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def charts(
|
|
18
|
+
extraction_id: str | None = typer.Argument(
|
|
19
|
+
None, help="ID of a completed extraction (omit when using --split-id)."
|
|
20
|
+
),
|
|
21
|
+
split_id: str | None = typer.Option(None, "--split-id", help="ID of a completed split result."),
|
|
22
|
+
data_points: int = typer.Option(
|
|
23
|
+
20, "--data-points", min=2, max=500, help="Points returned per series."
|
|
24
|
+
),
|
|
25
|
+
agentic_zoom: bool = typer.Option(
|
|
26
|
+
False, "--agentic-zoom", help="Refine small chart regions at full resolution."
|
|
27
|
+
),
|
|
28
|
+
page_range: str | None = typer.Option(None, "--pages", help='Extraction pages, e.g. "1-3,5".'),
|
|
29
|
+
export: list[str] = typer.Option(
|
|
30
|
+
[],
|
|
31
|
+
"--export",
|
|
32
|
+
"-e",
|
|
33
|
+
help="Repeatable export format: excel, csv, or las. Defaults to excel.",
|
|
34
|
+
),
|
|
35
|
+
download_exports: Path | None = typer.Option(
|
|
36
|
+
None,
|
|
37
|
+
"--download-exports",
|
|
38
|
+
help="Consume the one-time export links and write them to this directory.",
|
|
39
|
+
),
|
|
40
|
+
output: Path | None = typer.Option(
|
|
41
|
+
None, "--output", "-o", help="Write the complete response JSON to a file."
|
|
42
|
+
),
|
|
43
|
+
no_wait: bool = typer.Option(
|
|
44
|
+
False, "--no-wait", help="Submit and print the chart job ID without polling."
|
|
45
|
+
),
|
|
46
|
+
json_out: bool = typer.Option(False, "--json"),
|
|
47
|
+
api_key: str | None = typer.Option(None, "--api-key", envvar="PULSE_API_KEY"),
|
|
48
|
+
base_url: str | None = typer.Option(None, "--base-url"),
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Reconstruct chart data from a completed extraction or split."""
|
|
51
|
+
if bool(extraction_id) == bool(split_id):
|
|
52
|
+
raise fail("provide exactly one extraction_id or --split-id")
|
|
53
|
+
if split_id and page_range:
|
|
54
|
+
raise fail("--pages cannot be used with --split-id; pages come from the split")
|
|
55
|
+
|
|
56
|
+
formats = [value.strip().lower() for value in export] if export else ["excel"]
|
|
57
|
+
invalid = sorted(set(formats) - EXPORT_FORMATS)
|
|
58
|
+
if invalid:
|
|
59
|
+
raise fail(f"unsupported --export value: {', '.join(invalid)}")
|
|
60
|
+
formats = list(dict.fromkeys(formats))
|
|
61
|
+
|
|
62
|
+
payload = {
|
|
63
|
+
**({"extraction_id": extraction_id} if extraction_id else {}),
|
|
64
|
+
**({"split_id": split_id} if split_id else {}),
|
|
65
|
+
**({"page_range": page_range.strip()} if page_range and page_range.strip() else {}),
|
|
66
|
+
"async": True,
|
|
67
|
+
"charts_config": {
|
|
68
|
+
"data_points": data_points,
|
|
69
|
+
"agentic_zoom": agentic_zoom,
|
|
70
|
+
"export_formats": formats,
|
|
71
|
+
},
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
with get_client(api_key, base_url) as client:
|
|
75
|
+
submit = client.charts(payload)
|
|
76
|
+
job = submit_and_poll(client, submit, label="extracting charts", no_wait=no_wait)
|
|
77
|
+
if no_wait and job.get("job_id"):
|
|
78
|
+
console.print(f"[muted]submitted →[/muted] [brand]{job['job_id']}[/brand]")
|
|
79
|
+
return
|
|
80
|
+
status = str(job.get("status") or "").lower()
|
|
81
|
+
if is_terminal(status) and status != "completed":
|
|
82
|
+
error = job.get("error") or f"Chart extraction {status}."
|
|
83
|
+
raise fail(str(error))
|
|
84
|
+
job = resolve_offloaded_result(client, job)
|
|
85
|
+
result = job.get("result") if isinstance(job.get("result"), dict) else job
|
|
86
|
+
downloaded: list[str] = []
|
|
87
|
+
if download_exports:
|
|
88
|
+
download_exports.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
for artifact in result.get("exports", []):
|
|
90
|
+
filename = Path(str(artifact.get("filename") or "chart-export")).name
|
|
91
|
+
destination = download_exports / filename
|
|
92
|
+
destination.write_bytes(client.download_artifact(artifact["url"]))
|
|
93
|
+
downloaded.append(str(destination))
|
|
94
|
+
|
|
95
|
+
if downloaded:
|
|
96
|
+
result = {**result, "downloaded_exports": downloaded}
|
|
97
|
+
if output:
|
|
98
|
+
output.write_text(json.dumps(result, indent=2, default=str))
|
|
99
|
+
console.print(f"[ok]✓[/ok] wrote {output}")
|
|
100
|
+
elif json_out:
|
|
101
|
+
print_json(result)
|
|
102
|
+
else:
|
|
103
|
+
console.print(
|
|
104
|
+
f"[muted]charts_id =[/muted] [brand]{result.get('charts_id', 'unknown')}[/brand] "
|
|
105
|
+
f"[muted]· {result.get('count', 0)} chart(s)[/muted]"
|
|
106
|
+
)
|
|
107
|
+
print_json(result.get("charts") or result.get("results") or [])
|
|
108
|
+
if downloaded:
|
|
109
|
+
for path in downloaded:
|
|
110
|
+
console.print(f"[muted]SAVED:[/muted] {path}")
|
|
111
|
+
else:
|
|
112
|
+
for artifact in result.get("exports", []):
|
|
113
|
+
console.print(
|
|
114
|
+
f"[muted]{str(artifact.get('format', 'export')).upper()}:[/muted] "
|
|
115
|
+
f"{artifact.get('url')}"
|
|
116
|
+
)
|