pulse-ai-cli 0.1.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/.gitignore +3 -0
- pulse_ai_cli-0.1.0/PKG-INFO +49 -0
- pulse_ai_cli-0.1.0/README.md +23 -0
- pulse_ai_cli-0.1.0/docs.md +201 -0
- pulse_ai_cli-0.1.0/pulse_cli/__init__.py +3 -0
- pulse_ai_cli-0.1.0/pulse_cli/__main__.py +4 -0
- pulse_ai_cli-0.1.0/pulse_cli/app.py +103 -0
- pulse_ai_cli-0.1.0/pulse_cli/client.py +197 -0
- pulse_ai_cli-0.1.0/pulse_cli/commands/__init__.py +0 -0
- pulse_ai_cli-0.1.0/pulse_cli/commands/auth.py +114 -0
- pulse_ai_cli-0.1.0/pulse_cli/commands/extract.py +199 -0
- pulse_ai_cli-0.1.0/pulse_cli/commands/jobs.py +53 -0
- pulse_ai_cli-0.1.0/pulse_cli/commands/open_platform.py +25 -0
- pulse_ai_cli-0.1.0/pulse_cli/commands/schema.py +124 -0
- pulse_ai_cli-0.1.0/pulse_cli/commands/split.py +63 -0
- pulse_ai_cli-0.1.0/pulse_cli/commands/tables.py +52 -0
- pulse_ai_cli-0.1.0/pulse_cli/commands/usage.py +109 -0
- pulse_ai_cli-0.1.0/pulse_cli/config.py +141 -0
- pulse_ai_cli-0.1.0/pulse_cli/errors.py +85 -0
- pulse_ai_cli-0.1.0/pulse_cli/oauth.py +95 -0
- pulse_ai_cli-0.1.0/pulse_cli/output.py +87 -0
- pulse_ai_cli-0.1.0/pulse_cli/repl.py +149 -0
- pulse_ai_cli-0.1.0/pulse_cli/runtime.py +61 -0
- pulse_ai_cli-0.1.0/pulse_cli/status.py +10 -0
- pulse_ai_cli-0.1.0/pulse_cli/tui/__init__.py +0 -0
- pulse_ai_cli-0.1.0/pulse_cli/tui/dashboard.py +153 -0
- pulse_ai_cli-0.1.0/pulse_cli/tui/logo.py +76 -0
- pulse_ai_cli-0.1.0/pyproject.toml +45 -0
- pulse_ai_cli-0.1.0/tests/test_smoke.py +105 -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,23 @@
|
|
|
1
|
+
# Pulse CLI
|
|
2
|
+
|
|
3
|
+
Document extraction from your terminal — markdown, structured JSON schemas,
|
|
4
|
+
splits, and tables, powered by the [Pulse](https://runpulse.com) API.
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
uv tool install pulse-ai-cli
|
|
8
|
+
pulse login
|
|
9
|
+
pulse extract invoice.pdf --schema schemas/invoice.json
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
- `pulse extract` — documents (PDF, Office, images, URLs) → markdown + JSON;
|
|
13
|
+
add `--schema` to pull structured fields in the same run
|
|
14
|
+
- `pulse schema generate / apply` — AI-draft a JSON Schema, run it against
|
|
15
|
+
any extraction
|
|
16
|
+
- `pulse split` / `pulse tables` — topic-based splitting, table extraction
|
|
17
|
+
- `pulse jobs` — watch, resume, or cancel async jobs
|
|
18
|
+
- `pulse usage` — plan and page/credit usage
|
|
19
|
+
- bare `pulse` — interactive shell with the full command set
|
|
20
|
+
|
|
21
|
+
Every command supports `--json` for pipe-safe output and `-h` for full
|
|
22
|
+
options. See the [docs](https://docs.runpulse.com) for the complete
|
|
23
|
+
reference.
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# Pulse CLI
|
|
2
|
+
|
|
3
|
+
Extract documents, apply schemas, split, and pull tables from your terminal.
|
|
4
|
+
The CLI talks to the same Pulse API as the platform and MCP server — your API
|
|
5
|
+
key is the only credential.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
pulse extract invoice.pdf --schema schemas/invoice.json
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
uv tool install pulse-ai-cli
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
(or `pip install pulse-ai-cli`). Installs the `pulse` command. Requires
|
|
18
|
+
Python 3.11+.
|
|
19
|
+
|
|
20
|
+
## Authentication
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
pulse login
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Opens the Pulse platform in your browser; you approve a short code (e.g.
|
|
27
|
+
`ABCD-1234`) while signed in, and the CLI picks up your organization's API
|
|
28
|
+
key automatically (OAuth 2.1 device flow, RFC 8628). The key is validated
|
|
29
|
+
and stored in your OS keychain (or `~/.pulse/config.toml`, chmod 0600, if no
|
|
30
|
+
keychain is available).
|
|
31
|
+
|
|
32
|
+
No browser handy? `pulse login --api-key <key>` (or `pulse login --no-web`
|
|
33
|
+
to be prompted) logs in with an API key directly — find yours in the Pulse
|
|
34
|
+
platform under API Keys.
|
|
35
|
+
|
|
36
|
+
Alternatives, in priority order:
|
|
37
|
+
|
|
38
|
+
1. `--api-key <key>` on any command
|
|
39
|
+
2. `PULSE_API_KEY` environment variable (recommended for CI)
|
|
40
|
+
3. stored credential from `pulse login`
|
|
41
|
+
|
|
42
|
+
Related commands:
|
|
43
|
+
|
|
44
|
+
| command | what it does |
|
|
45
|
+
|---|---|
|
|
46
|
+
| `pulse whoami` | show the active (redacted) key, engine URL, and connectivity |
|
|
47
|
+
| `pulse logout` | remove the stored key |
|
|
48
|
+
|
|
49
|
+
`pulse login --base-url <url>` points the CLI at a different engine
|
|
50
|
+
(e.g. a VPC deployment). `--platform-url <url>` overrides the platform
|
|
51
|
+
(default `https://platform.runpulse.com`) used for browser login and
|
|
52
|
+
`pulse usage`.
|
|
53
|
+
|
|
54
|
+
## Commands
|
|
55
|
+
|
|
56
|
+
### `pulse extract <file|dir|url>…`
|
|
57
|
+
|
|
58
|
+
Convert documents to markdown + structured JSON. Accepts any mix of file
|
|
59
|
+
paths, directories (recursed for supported types), and URLs.
|
|
60
|
+
|
|
61
|
+
Writes `<name>.pulse.md` and `<name>.pulse.json` next to each input
|
|
62
|
+
(`-o <dir>` redirects; URL inputs land in the current directory).
|
|
63
|
+
|
|
64
|
+
| flag | description |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `--schema <file or inline JSON>` | pipeline: extract, then apply this JSON Schema — structured output is written to `<name>.pulse.schema.json` |
|
|
67
|
+
| `--pages "1-2,5"` | page range (1-indexed) |
|
|
68
|
+
| `--model <name>` | model override (`default`, `pulse-ultra-2`) |
|
|
69
|
+
| `--html` | also return an HTML rendition |
|
|
70
|
+
| `--footnotes` | link footnote markers to their text |
|
|
71
|
+
| `--figure-descriptions` | caption figures and visuals |
|
|
72
|
+
| `--show-images` | return image URLs for visuals |
|
|
73
|
+
| `--chunking semantic,page` | chunk types: `semantic`, `header`, `page`, `recursive` |
|
|
74
|
+
| `--chunk-size <n>` | max characters per chunk |
|
|
75
|
+
| `-o, --output <dir>` | directory for result files |
|
|
76
|
+
| `--no-wait` | submit and print the `job_id` without polling |
|
|
77
|
+
| `--json` | print the full JSON result to stdout, write no files |
|
|
78
|
+
|
|
79
|
+
Examples:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
pulse extract report.pdf
|
|
83
|
+
pulse extract invoice.pdf --schema '{"type":"object","properties":{"total":{"type":"number"}}}'
|
|
84
|
+
pulse extract ./contracts -o ./results --no-wait
|
|
85
|
+
pulse extract https://example.com/10k.pdf --pages "1-15" --chunking semantic
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Files up to 50 MB are uploaded directly; larger documents should be passed
|
|
89
|
+
by URL.
|
|
90
|
+
|
|
91
|
+
### `pulse schema`
|
|
92
|
+
|
|
93
|
+
Structured extraction against a *previous* extraction (use `extract --schema` to do
|
|
94
|
+
both in one command).
|
|
95
|
+
|
|
96
|
+
`pulse schema generate` — AI-draft a JSON Schema:
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
pulse schema generate -p "vendor name, line items, total due" -o invoice.json
|
|
100
|
+
pulse schema generate --from invoice.json -p "also capture the PO number"
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`pulse schema apply` — run a schema against extracted content:
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
pulse schema apply <extraction_id> --schema invoice.json
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
| flag | description |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `--schema <file>` | JSON Schema file |
|
|
112
|
+
| `-p, --prompt <text>` | extra extraction guidance (works with or without `--schema`) |
|
|
113
|
+
| `--extraction-ids a,b,c` | combine several extractions |
|
|
114
|
+
| `--split-id <id>` | apply per-topic schemas to a split |
|
|
115
|
+
| `--split-schema-config <file>` | per-topic schema mapping (with `--split-id`) |
|
|
116
|
+
| `--schema-config-id <id>` | use a saved platform schema config |
|
|
117
|
+
| `--effort` | higher-effort extraction |
|
|
118
|
+
| `--pages "1-3"` | restrict to a page range |
|
|
119
|
+
| `-o <file>` | write result JSON to a file (default: stdout) |
|
|
120
|
+
|
|
121
|
+
### `pulse split <extraction_id>`
|
|
122
|
+
|
|
123
|
+
Split a document into sections by topic:
|
|
124
|
+
|
|
125
|
+
```
|
|
126
|
+
pulse split <extraction_id> -t "introduction:opening section" -t "financials:tables and figures"
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`-t "name:description"` is repeatable; `--topics-file <json>` and
|
|
130
|
+
`--split-config-id <id>` are alternatives. Returns per-topic page ranges and
|
|
131
|
+
content; the resulting `split_id` can feed `pulse schema apply --split-id`.
|
|
132
|
+
|
|
133
|
+
### `pulse tables <extraction_id>`
|
|
134
|
+
|
|
135
|
+
Extract tables from a prior extraction:
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
pulse tables <extraction_id> --format html
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`--format html|json`, `--merge` (join tables spanning pages),
|
|
142
|
+
`--charts-to-tables`, `-o <file>`.
|
|
143
|
+
|
|
144
|
+
### `pulse jobs`
|
|
145
|
+
|
|
146
|
+
Every long-running command runs async under the hood and polls for you.
|
|
147
|
+
`--no-wait` (or Ctrl-C while polling — the job keeps running server-side)
|
|
148
|
+
hands control back; resume any time:
|
|
149
|
+
|
|
150
|
+
```
|
|
151
|
+
pulse jobs get <job_id> --watch
|
|
152
|
+
pulse jobs cancel <job_id>
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### `pulse open`
|
|
156
|
+
|
|
157
|
+
Jump from the terminal to the platform:
|
|
158
|
+
|
|
159
|
+
```
|
|
160
|
+
pulse open <extraction_id>
|
|
161
|
+
pulse open
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Opens the extraction (or the dashboard) at `platform.runpulse.com` in your
|
|
165
|
+
browser.
|
|
166
|
+
|
|
167
|
+
### `pulse usage`
|
|
168
|
+
|
|
169
|
+
Plan and usage for the current billing period — pages used vs allowance, plus
|
|
170
|
+
a breakdown (documents, pages, latency, by source):
|
|
171
|
+
|
|
172
|
+
```
|
|
173
|
+
pulse usage
|
|
174
|
+
pulse usage --days 7
|
|
175
|
+
pulse usage --json
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Requires a platform URL (`pulse login --platform-url …` or
|
|
179
|
+
`PULSE_PLATFORM_URL`).
|
|
180
|
+
|
|
181
|
+
### `pulse dashboard`
|
|
182
|
+
|
|
183
|
+
Full-screen status view (account, usage). Bare `pulse` in a terminal opens
|
|
184
|
+
the interactive shell.
|
|
185
|
+
|
|
186
|
+
## Output conventions
|
|
187
|
+
|
|
188
|
+
- Human-readable progress goes to the terminal; `--json` switches any command
|
|
189
|
+
to raw, pipe-safe JSON on stdout.
|
|
190
|
+
- File-writing commands echo every path they write, plus the `extraction_id`
|
|
191
|
+
you'll need for follow-up commands.
|
|
192
|
+
- Exit codes: `0` success, `1` failure (any document in a batch failing),
|
|
193
|
+
`130` interrupted (job still running server-side).
|
|
194
|
+
|
|
195
|
+
## Roadmap
|
|
196
|
+
|
|
197
|
+
- `extract --split … --schema …` end-to-end pipeline (split a document, then apply
|
|
198
|
+
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
|
+
- Full OAuth token auth for the MCP server (auth-code + PKCE, DCR).
|
|
@@ -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()
|
|
@@ -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
|