pulse-ai-cli 0.1.0__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.
- pulse_ai_cli-0.1.0.dist-info/METADATA +49 -0
- pulse_ai_cli-0.1.0.dist-info/RECORD +27 -0
- pulse_ai_cli-0.1.0.dist-info/WHEEL +4 -0
- pulse_ai_cli-0.1.0.dist-info/entry_points.txt +2 -0
- pulse_cli/__init__.py +3 -0
- pulse_cli/__main__.py +4 -0
- pulse_cli/app.py +103 -0
- pulse_cli/client.py +197 -0
- pulse_cli/commands/__init__.py +0 -0
- pulse_cli/commands/auth.py +114 -0
- pulse_cli/commands/extract.py +199 -0
- pulse_cli/commands/jobs.py +53 -0
- pulse_cli/commands/open_platform.py +25 -0
- pulse_cli/commands/schema.py +124 -0
- pulse_cli/commands/split.py +63 -0
- pulse_cli/commands/tables.py +52 -0
- pulse_cli/commands/usage.py +109 -0
- pulse_cli/config.py +141 -0
- pulse_cli/errors.py +85 -0
- pulse_cli/oauth.py +95 -0
- pulse_cli/output.py +87 -0
- pulse_cli/repl.py +149 -0
- pulse_cli/runtime.py +61 -0
- pulse_cli/status.py +10 -0
- pulse_cli/tui/__init__.py +0 -0
- pulse_cli/tui/dashboard.py +153 -0
- pulse_cli/tui/logo.py +76 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pulse-ai-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pulse CLI — document extraction from your terminal (runpulse.com)
|
|
5
|
+
Project-URL: Homepage, https://runpulse.com
|
|
6
|
+
Project-URL: Documentation, https://docs.runpulse.com
|
|
7
|
+
License: Proprietary
|
|
8
|
+
Keywords: cli,document-extraction,ocr,pdf,pulse
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Requires-Dist: click>=8
|
|
16
|
+
Requires-Dist: httpx>=0.27
|
|
17
|
+
Requires-Dist: rich>=13.7
|
|
18
|
+
Requires-Dist: textual>=0.80
|
|
19
|
+
Requires-Dist: typer>=0.12
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
22
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
23
|
+
Provides-Extra: keyring
|
|
24
|
+
Requires-Dist: keyring>=24; extra == 'keyring'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# Pulse CLI
|
|
28
|
+
|
|
29
|
+
Document extraction from your terminal — markdown, structured JSON schemas,
|
|
30
|
+
splits, and tables, powered by the [Pulse](https://runpulse.com) API.
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
uv tool install pulse-ai-cli
|
|
34
|
+
pulse login
|
|
35
|
+
pulse extract invoice.pdf --schema schemas/invoice.json
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- `pulse extract` — documents (PDF, Office, images, URLs) → markdown + JSON;
|
|
39
|
+
add `--schema` to pull structured fields in the same run
|
|
40
|
+
- `pulse schema generate / apply` — AI-draft a JSON Schema, run it against
|
|
41
|
+
any extraction
|
|
42
|
+
- `pulse split` / `pulse tables` — topic-based splitting, table extraction
|
|
43
|
+
- `pulse jobs` — watch, resume, or cancel async jobs
|
|
44
|
+
- `pulse usage` — plan and page/credit usage
|
|
45
|
+
- bare `pulse` — interactive shell with the full command set
|
|
46
|
+
|
|
47
|
+
Every command supports `--json` for pipe-safe output and `-h` for full
|
|
48
|
+
options. See the [docs](https://docs.runpulse.com) for the complete
|
|
49
|
+
reference.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
pulse_cli/__init__.py,sha256=rKvOFqvHdAA7qtswaBle803jzPxzQLbHcBgCq4-opBs,40
|
|
2
|
+
pulse_cli/__main__.py,sha256=IGhIa-Q2mk1JcfJVNOJWh5nYpkKtz0hQzU1OWjnPsG0,68
|
|
3
|
+
pulse_cli/app.py,sha256=jG3NDp-gJCeR-Ft261bE38IcI9X54xSf8lQsL_F67oM,3316
|
|
4
|
+
pulse_cli/client.py,sha256=cmtiSxPrNzUVFuULnzg8mieA5d168PgAmYFauwbDSTc,7740
|
|
5
|
+
pulse_cli/config.py,sha256=faeXOf5J-H1lbI13aEiCuPTcv1XYaow68O4qJazdwp0,4660
|
|
6
|
+
pulse_cli/errors.py,sha256=qIpj8tBcQbzNAHR7wIXd8Ful25bqaGF2SV71YP-ZK0s,3518
|
|
7
|
+
pulse_cli/oauth.py,sha256=eLc5GsPoVQLG5-TGanoW73wPLTI3CkHvrJtJczwDSQM,3812
|
|
8
|
+
pulse_cli/output.py,sha256=Dk_HNpU0_tXMDM7BL0VPRDYtVDLStisIwilxko8skYg,2544
|
|
9
|
+
pulse_cli/repl.py,sha256=wcjwy3MuKxTQz9PU3ZsvZmYsPwLhv0Qb_5MAyNBk_sA,4694
|
|
10
|
+
pulse_cli/runtime.py,sha256=ZxBPJ40NjCE2nlW-qDVwMOodJ3A2JcZXxNQneU3YrLY,2558
|
|
11
|
+
pulse_cli/status.py,sha256=yja6sIGhJV2TleHWjQd69t-y3Pbp3t0N_jz1qZwk_NQ,350
|
|
12
|
+
pulse_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
|
+
pulse_cli/commands/auth.py,sha256=YkDP7h7jYocJ2HVXnBeIT_HKSdpFMvIvJmoS8b_UMZQ,4393
|
|
14
|
+
pulse_cli/commands/extract.py,sha256=SAJRL1kUMPlZLosEKkXgE0yPzc6Ug_6E_VfbRjSC3XI,8738
|
|
15
|
+
pulse_cli/commands/jobs.py,sha256=J7LnUD7XK9DOnRtetHPkqyijxu2JXe-rRflZPBw-iT0,2147
|
|
16
|
+
pulse_cli/commands/open_platform.py,sha256=FHn9KSnxrkhERkP81XY7VkR6jLe1wrilcLtgfv0JHA0,848
|
|
17
|
+
pulse_cli/commands/schema.py,sha256=pRMCu1WLBc9WlGNk8sfnYy_blbt_f_exW_dk39rbm94,5701
|
|
18
|
+
pulse_cli/commands/split.py,sha256=bDnNi6nRjub2v7n9LyhPLd3xwWgmo4s6jbFRPVGSXck,2654
|
|
19
|
+
pulse_cli/commands/tables.py,sha256=YrWkcQyZe-YKi2_QRTsxgVQFqCfGlrIAy8ge9skSY0g,2241
|
|
20
|
+
pulse_cli/commands/usage.py,sha256=p6GscHrLAp7z6-5b2TvTdsFLLx2dkjbm2B9KIqywdws,4440
|
|
21
|
+
pulse_cli/tui/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
22
|
+
pulse_cli/tui/dashboard.py,sha256=hpV03wuyQUbdLdTbWrLN9me3yaiSCHxF2HWKt8yav_0,6312
|
|
23
|
+
pulse_cli/tui/logo.py,sha256=E2h_tck3XuorGxAs2i4QMk42oq60V9-fJoTQy7ffVGA,2831
|
|
24
|
+
pulse_ai_cli-0.1.0.dist-info/METADATA,sha256=bR0b2eLDyKx5wdX_MT9G8usBKiPA6znTUyRRITcqoM0,1800
|
|
25
|
+
pulse_ai_cli-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
26
|
+
pulse_ai_cli-0.1.0.dist-info/entry_points.txt,sha256=2a29WIj6AS4LDvD9vHvmfQK-QSFBzGpjisDTJDP3sTw,44
|
|
27
|
+
pulse_ai_cli-0.1.0.dist-info/RECORD,,
|
pulse_cli/__init__.py
ADDED
pulse_cli/__main__.py
ADDED
pulse_cli/app.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Pulse CLI root app."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
|
|
6
|
+
from pulse_cli import __version__
|
|
7
|
+
from pulse_cli.commands.auth import login, logout, whoami
|
|
8
|
+
from pulse_cli.commands.extract import extract
|
|
9
|
+
from pulse_cli.commands.jobs import jobs_app
|
|
10
|
+
from pulse_cli.commands.open_platform import open_platform
|
|
11
|
+
from pulse_cli.commands.schema import schema_app
|
|
12
|
+
from pulse_cli.commands.split import split
|
|
13
|
+
from pulse_cli.commands.tables import tables
|
|
14
|
+
from pulse_cli.commands.usage import usage
|
|
15
|
+
from pulse_cli.errors import PulseCliError
|
|
16
|
+
from pulse_cli.output import err_console
|
|
17
|
+
|
|
18
|
+
# typer joins epilog paragraphs (separated by blank lines) with single
|
|
19
|
+
# newlines, so each example below renders on its own line.
|
|
20
|
+
_EXAMPLES = [
|
|
21
|
+
("pulse extract report.pdf", "document -> report.pulse.md / .json"),
|
|
22
|
+
("pulse extract invoice.pdf --schema schema.json", "extract + structured fields in one go"),
|
|
23
|
+
("pulse extract ./docs --no-wait", "submit a folder, fetch results later"),
|
|
24
|
+
('pulse schema generate -p "vendor, total, date"', "AI-draft a JSON schema"),
|
|
25
|
+
("pulse schema apply <extraction_id> --schema s.json", "structured fields from a past extract"),
|
|
26
|
+
('pulse split <extraction_id> -t "intro:first part"', "split a document by topic"),
|
|
27
|
+
("pulse tables <extraction_id>", "pull out tables"),
|
|
28
|
+
("pulse jobs get <job_id> --watch", "watch or resume an async job"),
|
|
29
|
+
("pulse open <extraction_id>", "view an extraction on the platform"),
|
|
30
|
+
("pulse usage", "plan + credit/page usage"),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
_EPILOG = "\n\n".join(
|
|
34
|
+
["[bold]Examples[/bold]"]
|
|
35
|
+
+ [f"[cyan]{cmd:<52}[/cyan] [dim]{desc}[/dim]" for cmd, desc in _EXAMPLES]
|
|
36
|
+
+ [
|
|
37
|
+
"",
|
|
38
|
+
"Add [cyan]--json[/cyan] to any command for raw output, [cyan]-o[/cyan] to choose "
|
|
39
|
+
"where files go, and [cyan]-h[/cyan] after any command for its full options.",
|
|
40
|
+
]
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
cli = typer.Typer(
|
|
44
|
+
name="pulse",
|
|
45
|
+
help="Pulse — document extraction from your terminal (runpulse.com).",
|
|
46
|
+
epilog=_EPILOG,
|
|
47
|
+
rich_markup_mode="rich",
|
|
48
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@cli.callback(invoke_without_command=True)
|
|
53
|
+
def _root(ctx: typer.Context) -> None:
|
|
54
|
+
"""Bare `pulse` in a terminal opens the interactive shell; otherwise show help."""
|
|
55
|
+
if ctx.invoked_subcommand is None:
|
|
56
|
+
import sys
|
|
57
|
+
|
|
58
|
+
if sys.stdin.isatty() and sys.stdout.isatty():
|
|
59
|
+
from pulse_cli.repl import run_repl
|
|
60
|
+
|
|
61
|
+
run_repl()
|
|
62
|
+
else:
|
|
63
|
+
print(ctx.get_help())
|
|
64
|
+
raise typer.Exit()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@cli.command()
|
|
68
|
+
def dashboard() -> None:
|
|
69
|
+
"""Open the Pulse dashboard (account + usage)."""
|
|
70
|
+
from pulse_cli.tui.dashboard import run_dashboard
|
|
71
|
+
|
|
72
|
+
run_dashboard()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
cli.command()(login)
|
|
76
|
+
cli.command()(logout)
|
|
77
|
+
cli.command()(whoami)
|
|
78
|
+
cli.command()(extract)
|
|
79
|
+
cli.command()(split)
|
|
80
|
+
cli.command()(tables)
|
|
81
|
+
cli.command()(usage)
|
|
82
|
+
cli.command("open")(open_platform)
|
|
83
|
+
cli.add_typer(schema_app, name="schema")
|
|
84
|
+
cli.add_typer(jobs_app, name="jobs")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@cli.command()
|
|
88
|
+
def version() -> None:
|
|
89
|
+
"""Print the CLI version."""
|
|
90
|
+
print(f"pulse-cli {__version__}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def app() -> None:
|
|
94
|
+
"""Console-script entry point with clean error rendering."""
|
|
95
|
+
try:
|
|
96
|
+
cli()
|
|
97
|
+
except PulseCliError as exc:
|
|
98
|
+
err_console.print(f"[err]error:[/err] {exc}")
|
|
99
|
+
raise SystemExit(1)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
app()
|
pulse_cli/client.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Sync HTTP client for the Pulse engine — a CLI port of mcp_server/pulse_client.py.
|
|
2
|
+
|
|
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
|
|
5
|
+
key forwarded verbatim as ``x-api-key``; the engine enforces auth/credits/limits.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any, Callable, Optional
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
|
|
15
|
+
from pulse_cli import __version__
|
|
16
|
+
from pulse_cli.errors import PulseEngineError
|
|
17
|
+
from pulse_cli.status import is_terminal
|
|
18
|
+
|
|
19
|
+
MAX_UPLOAD_BYTES = 50 * 1024 * 1024 # engine upload cap
|
|
20
|
+
|
|
21
|
+
DEFAULT_TIMEOUT = 120.0
|
|
22
|
+
SCHEMA_HELPER_TIMEOUT = 240.0 # /ai-schema-helper is sync and can run long
|
|
23
|
+
POLL_INTERVAL_SECONDS = 2.0
|
|
24
|
+
|
|
25
|
+
# (status, poll_count, job_dict) -> None
|
|
26
|
+
ProgressCallback = Callable[[str, int, dict], None]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _form_value(v: Any) -> str:
|
|
30
|
+
if isinstance(v, bool):
|
|
31
|
+
return "true" if v else "false"
|
|
32
|
+
if isinstance(v, (dict, list)):
|
|
33
|
+
return json.dumps(v)
|
|
34
|
+
return str(v)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PulseClient:
|
|
38
|
+
def __init__(self, api_key: str, base_url: str, *, transport: Optional[httpx.BaseTransport] = None) -> None:
|
|
39
|
+
self._client = httpx.Client(
|
|
40
|
+
base_url=base_url.rstrip("/"),
|
|
41
|
+
timeout=DEFAULT_TIMEOUT,
|
|
42
|
+
headers={"x-api-key": api_key, "user-agent": f"pulse-cli/{__version__}"},
|
|
43
|
+
transport=transport,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def __enter__(self) -> "PulseClient":
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
def __exit__(self, *exc: Any) -> None:
|
|
50
|
+
self._client.close()
|
|
51
|
+
|
|
52
|
+
# --- core -----------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> Any:
|
|
55
|
+
try:
|
|
56
|
+
resp = self._client.request(method, path, **kwargs)
|
|
57
|
+
except httpx.TimeoutException as exc:
|
|
58
|
+
raise PulseEngineError(504, engine_message="The Pulse engine took too long to respond.") from exc
|
|
59
|
+
except httpx.RequestError as exc:
|
|
60
|
+
raise PulseEngineError(
|
|
61
|
+
502, engine_message=f"Could not reach the Pulse engine at {self._client.base_url}."
|
|
62
|
+
) from exc
|
|
63
|
+
|
|
64
|
+
if resp.is_success:
|
|
65
|
+
if "application/json" in resp.headers.get("content-type", ""):
|
|
66
|
+
return resp.json()
|
|
67
|
+
return resp.text
|
|
68
|
+
try:
|
|
69
|
+
body: Any = resp.json()
|
|
70
|
+
except Exception:
|
|
71
|
+
body = resp.text
|
|
72
|
+
raise PulseEngineError.from_response(resp.status_code, body)
|
|
73
|
+
|
|
74
|
+
# --- endpoints (parity with mcp_server) ------------------------------------
|
|
75
|
+
|
|
76
|
+
def get_job(self, job_id: str) -> dict:
|
|
77
|
+
return self._request("GET", f"/job/{job_id}")
|
|
78
|
+
|
|
79
|
+
def cancel_job(self, job_id: str) -> dict:
|
|
80
|
+
return self._request("POST", f"/cancel/{job_id}")
|
|
81
|
+
|
|
82
|
+
def submit_extract(self, payload: dict) -> dict:
|
|
83
|
+
"""POST /extract (JSON) — for file_url inputs; set async=True for a job_id."""
|
|
84
|
+
return self._request("POST", "/extract", json=payload)
|
|
85
|
+
|
|
86
|
+
def submit_extract_multipart(self, file: tuple, options: dict) -> dict:
|
|
87
|
+
"""POST /extract (multipart) — local file bytes + form options."""
|
|
88
|
+
data = {k: _form_value(v) for k, v in options.items() if v is not None}
|
|
89
|
+
return self._request("POST", "/extract", data=data, files={"file": file})
|
|
90
|
+
|
|
91
|
+
def apply_schema(self, payload: dict) -> dict:
|
|
92
|
+
return self._request("POST", "/schema", json=payload)
|
|
93
|
+
|
|
94
|
+
def split(self, payload: dict) -> dict:
|
|
95
|
+
return self._request("POST", "/split", json=payload)
|
|
96
|
+
|
|
97
|
+
def tables(self, payload: dict) -> dict:
|
|
98
|
+
return self._request("POST", "/tables", json=payload)
|
|
99
|
+
|
|
100
|
+
def generate_schema(self, *, prompt: Optional[str] = None, current_schema: Optional[str] = None) -> dict:
|
|
101
|
+
data: dict[str, str] = {}
|
|
102
|
+
if prompt is not None:
|
|
103
|
+
data["prompt"] = prompt
|
|
104
|
+
if current_schema is not None:
|
|
105
|
+
data["current_schema"] = current_schema
|
|
106
|
+
return self._request("POST", "/ai-schema-helper", data=data, timeout=SCHEMA_HELPER_TIMEOUT)
|
|
107
|
+
|
|
108
|
+
def pages(self) -> dict:
|
|
109
|
+
"""GET /pages — total pages processed (api-key safe; also used to validate keys)."""
|
|
110
|
+
return self._request("GET", "/pages")
|
|
111
|
+
|
|
112
|
+
# --- polling ----------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def wait_for_job(
|
|
115
|
+
self,
|
|
116
|
+
job_id: str,
|
|
117
|
+
*,
|
|
118
|
+
interval_seconds: float = POLL_INTERVAL_SECONDS,
|
|
119
|
+
on_progress: Optional[ProgressCallback] = None,
|
|
120
|
+
) -> dict:
|
|
121
|
+
"""Poll GET /job/{job_id} until terminal. The CLI polls indefinitely
|
|
122
|
+
(unlike MCP's inline budget) — Ctrl-C leaves the job running server-side."""
|
|
123
|
+
polls = 0
|
|
124
|
+
job = self.get_job(job_id)
|
|
125
|
+
while not is_terminal(job.get("status")):
|
|
126
|
+
polls += 1
|
|
127
|
+
if on_progress is not None:
|
|
128
|
+
on_progress(job.get("status", ""), polls, job)
|
|
129
|
+
time.sleep(interval_seconds)
|
|
130
|
+
job = self.get_job(job_id)
|
|
131
|
+
return job
|
|
132
|
+
|
|
133
|
+
def fetch_url(self, url: str) -> Any:
|
|
134
|
+
"""Download an offloaded {is_url, url} result (pre-signed S3)."""
|
|
135
|
+
resp = httpx.get(url, timeout=DEFAULT_TIMEOUT, follow_redirects=True)
|
|
136
|
+
if not resp.is_success:
|
|
137
|
+
raise PulseEngineError(resp.status_code, engine_message="Failed to download result from URL.")
|
|
138
|
+
if "application/json" in resp.headers.get("content-type", ""):
|
|
139
|
+
return resp.json()
|
|
140
|
+
try:
|
|
141
|
+
return json.loads(resp.text)
|
|
142
|
+
except json.JSONDecodeError:
|
|
143
|
+
return resp.text
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class PlatformClient:
|
|
147
|
+
"""Minimal client for the FastAPI service (api-key-safe, read-only endpoints)."""
|
|
148
|
+
|
|
149
|
+
def __init__(self, api_key: str, base_url: str) -> None:
|
|
150
|
+
self._client = httpx.Client(
|
|
151
|
+
base_url=base_url.rstrip("/"),
|
|
152
|
+
timeout=30.0,
|
|
153
|
+
headers={"x-api-key": api_key, "user-agent": f"pulse-cli/{__version__}"},
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def __enter__(self) -> "PlatformClient":
|
|
157
|
+
return self
|
|
158
|
+
|
|
159
|
+
def __exit__(self, *exc: Any) -> None:
|
|
160
|
+
self._client.close()
|
|
161
|
+
|
|
162
|
+
def _get(self, path: str, params: Optional[dict] = None) -> dict:
|
|
163
|
+
try:
|
|
164
|
+
resp = self._client.get(path, params=params)
|
|
165
|
+
except httpx.TimeoutException as exc:
|
|
166
|
+
raise PulseEngineError(504, engine_message="The Pulse platform took too long to respond.") from exc
|
|
167
|
+
except httpx.RequestError as exc:
|
|
168
|
+
raise PulseEngineError(
|
|
169
|
+
502, engine_message=f"Could not reach the Pulse platform at {self._client.base_url}."
|
|
170
|
+
) from exc
|
|
171
|
+
if resp.is_success:
|
|
172
|
+
return resp.json()
|
|
173
|
+
try:
|
|
174
|
+
body: Any = resp.json()
|
|
175
|
+
except Exception:
|
|
176
|
+
body = resp.text
|
|
177
|
+
raise PulseEngineError.from_response(resp.status_code, body)
|
|
178
|
+
|
|
179
|
+
def subscription(self) -> Optional[dict]:
|
|
180
|
+
"""GET /api/v1/billing/subscription — plan/status; None if unreachable."""
|
|
181
|
+
try:
|
|
182
|
+
return self._get("/api/v1/billing/subscription")
|
|
183
|
+
except PulseEngineError:
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
def current_period_usage(self) -> dict:
|
|
187
|
+
"""GET /api/v1/analytics/usage/current-period — plan + pages for the billing cycle."""
|
|
188
|
+
return self._get("/api/v1/analytics/usage/current-period")
|
|
189
|
+
|
|
190
|
+
def usage_breakdown(self, start_date: Optional[str] = None, end_date: Optional[str] = None) -> dict:
|
|
191
|
+
"""GET /api/v1/analytics/usage/breakdown — docs/pages/latency, by source.
|
|
192
|
+
|
|
193
|
+
Without dates the platform defaults to the current billing period
|
|
194
|
+
(or lifetime for lifetime plans).
|
|
195
|
+
"""
|
|
196
|
+
params = {k: v for k, v in (("start_date", start_date), ("end_date", end_date)) if v}
|
|
197
|
+
return self._get("/api/v1/analytics/usage/breakdown", params=params or None)
|
|
File without changes
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""`pulse login` / `pulse logout` / `pulse whoami`."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from pulse_cli import config as cfg
|
|
9
|
+
from pulse_cli.client import PlatformClient, PulseClient
|
|
10
|
+
from pulse_cli.errors import PulseCliError
|
|
11
|
+
from pulse_cli.output import console, fail, print_json
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def login(
|
|
15
|
+
api_key: Optional[str] = typer.Option(None, "--api-key", help="Skip the browser and log in with an API key directly."),
|
|
16
|
+
base_url: Optional[str] = typer.Option(None, "--base-url", help="Engine URL override (default: api.runpulse.com)."),
|
|
17
|
+
platform_url: Optional[str] = typer.Option(None, "--platform-url", help="Platform API URL (enables `pulse usage`)."),
|
|
18
|
+
web: bool = typer.Option(True, "--web/--no-web", help="Browser login (default). --no-web prompts for an API key instead."),
|
|
19
|
+
) -> None:
|
|
20
|
+
"""Log in via the browser (default) or with an API key; the key is validated, then stored securely."""
|
|
21
|
+
if api_key:
|
|
22
|
+
key = api_key
|
|
23
|
+
elif web:
|
|
24
|
+
key = _device_flow_login(platform_url)
|
|
25
|
+
else:
|
|
26
|
+
key = typer.prompt("Pulse API key", hide_input=True)
|
|
27
|
+
key = key.strip()
|
|
28
|
+
if not key:
|
|
29
|
+
raise fail("empty API key")
|
|
30
|
+
|
|
31
|
+
conf = cfg.load(api_url_override=base_url)
|
|
32
|
+
console.print(f"[muted]Validating key against {conf.api_url} …[/muted]")
|
|
33
|
+
try:
|
|
34
|
+
with PulseClient(key, conf.api_url) as client:
|
|
35
|
+
pages = client.pages()
|
|
36
|
+
except PulseCliError as exc:
|
|
37
|
+
raise fail(str(exc))
|
|
38
|
+
|
|
39
|
+
store = cfg.save_api_key(key)
|
|
40
|
+
if base_url or platform_url:
|
|
41
|
+
cfg.save_settings(api_url=base_url, platform_url=platform_url)
|
|
42
|
+
|
|
43
|
+
where = "OS keychain" if store == "keyring" else f"{cfg.CONFIG_PATH} (0600)"
|
|
44
|
+
console.print(f"[ok]Logged in.[/ok] Key {cfg.redact(key)} stored in {where}.")
|
|
45
|
+
console.print(f"[muted]Pages processed so far:[/muted] {pages.get('total_pages_processed', 0)}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _device_flow_login(platform_url_flag: Optional[str]) -> str:
|
|
49
|
+
"""Browser-based device flow: approve in the platform, get the org API key back."""
|
|
50
|
+
import webbrowser
|
|
51
|
+
|
|
52
|
+
from pulse_cli import oauth
|
|
53
|
+
|
|
54
|
+
conf = cfg.load()
|
|
55
|
+
purl = platform_url_flag or conf.platform_url
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
auth = oauth.device_authorization(purl)
|
|
59
|
+
console.print(f"\n Your code: [bold]{auth['user_code']}[/bold]")
|
|
60
|
+
console.print(f" Approve at: [link]{auth['verification_uri_complete']}[/link]\n")
|
|
61
|
+
webbrowser.open(auth["verification_uri_complete"])
|
|
62
|
+
with console.status("[muted]Waiting for approval in the browser …[/muted]"):
|
|
63
|
+
tokens = oauth.poll_for_tokens(
|
|
64
|
+
purl,
|
|
65
|
+
auth["device_code"],
|
|
66
|
+
interval=int(auth.get("interval", 5)),
|
|
67
|
+
expires_in=int(auth.get("expires_in", 600)),
|
|
68
|
+
)
|
|
69
|
+
return oauth.fetch_api_key(purl, tokens["access_token"])
|
|
70
|
+
except PulseCliError as exc:
|
|
71
|
+
raise fail(str(exc))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def logout() -> None:
|
|
75
|
+
"""Remove the stored API key."""
|
|
76
|
+
if cfg.clear_api_key():
|
|
77
|
+
console.print("[ok]Logged out.[/ok] Stored API key removed.")
|
|
78
|
+
else:
|
|
79
|
+
console.print("[muted]No stored API key found.[/muted]")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def whoami(
|
|
83
|
+
json_out: bool = typer.Option(False, "--json", help="Machine-readable output."),
|
|
84
|
+
base_url: Optional[str] = typer.Option(None, "--base-url", help="Engine URL override."),
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Show the active key (redacted), where it came from, and account status."""
|
|
87
|
+
conf = cfg.load(api_url_override=base_url)
|
|
88
|
+
if not conf.api_key:
|
|
89
|
+
raise fail("not logged in. Run `pulse login`.")
|
|
90
|
+
|
|
91
|
+
info: dict = {
|
|
92
|
+
"api_key": cfg.redact(conf.api_key),
|
|
93
|
+
"key_source": conf.key_source,
|
|
94
|
+
"api_url": conf.api_url,
|
|
95
|
+
}
|
|
96
|
+
try:
|
|
97
|
+
with PulseClient(conf.api_key, conf.api_url) as client:
|
|
98
|
+
info["total_pages_processed"] = client.pages().get("total_pages_processed")
|
|
99
|
+
except PulseCliError as exc:
|
|
100
|
+
info["status"] = f"key check failed: {exc}"
|
|
101
|
+
|
|
102
|
+
if conf.platform_url:
|
|
103
|
+
with PlatformClient(conf.api_key, conf.platform_url) as pc:
|
|
104
|
+
sub = pc.subscription()
|
|
105
|
+
if sub:
|
|
106
|
+
info["plan"] = sub.get("plan") or sub.get("plan_name")
|
|
107
|
+
info["subscription_status"] = sub.get("status")
|
|
108
|
+
|
|
109
|
+
if json_out:
|
|
110
|
+
print_json(info)
|
|
111
|
+
return
|
|
112
|
+
for k, v in info.items():
|
|
113
|
+
if v is not None:
|
|
114
|
+
console.print(f"[muted]{k:24}[/muted] [body]{v}[/body]")
|