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.
@@ -0,0 +1,199 @@
1
+ """`pulse extract <file|url|dir> …` — document -> markdown (+ JSON result)."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import mimetypes
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import typer
10
+
11
+ from pulse_cli.client import MAX_UPLOAD_BYTES
12
+ from pulse_cli.errors import PulseCliError
13
+ from pulse_cli.output import console, derive_output_stem, fail, print_json, write_result_files
14
+ from pulse_cli.runtime import get_client, resolve_offloaded_result, submit_and_poll
15
+
16
+ SUPPORTED_EXTS = {
17
+ ".pdf", ".png", ".jpg", ".jpeg", ".tiff", ".webp",
18
+ ".docx", ".doc", ".pptx", ".ppt", ".xlsx", ".xls", ".csv",
19
+ ".html", ".txt", ".md",
20
+ }
21
+
22
+
23
+ def _collect_sources(inputs: list[str]) -> list[str]:
24
+ """Expand directories (recursive) into supported files; keep URLs/files as-is."""
25
+ sources: list[str] = []
26
+ for item in inputs:
27
+ if item.startswith(("http://", "https://")):
28
+ sources.append(item)
29
+ continue
30
+ p = Path(item).expanduser()
31
+ if p.is_dir():
32
+ found = sorted(
33
+ str(f) for f in p.rglob("*")
34
+ if f.is_file() and f.suffix.lower() in SUPPORTED_EXTS
35
+ )
36
+ if not found:
37
+ console.print(f"[muted]no supported documents in {p}[/muted]")
38
+ sources.extend(found)
39
+ elif p.is_file():
40
+ sources.append(str(p))
41
+ else:
42
+ raise fail(f"not found: {item}")
43
+ return sources
44
+
45
+
46
+ def _parse_schema_arg(value: str) -> dict:
47
+ """`--schema` accepts a JSON Schema file path or inline JSON."""
48
+ text = value.strip()
49
+ if text.startswith("{"):
50
+ try:
51
+ return json.loads(text)
52
+ except json.JSONDecodeError as exc:
53
+ raise fail(f"--schema inline JSON is invalid ({exc})")
54
+ p = Path(text).expanduser()
55
+ if not p.is_file():
56
+ raise fail(f"--schema file not found: {p}")
57
+ try:
58
+ return json.loads(p.read_text())
59
+ except json.JSONDecodeError as exc:
60
+ raise fail(f"--schema {p} is not valid JSON ({exc})")
61
+
62
+
63
+ def _build_options(
64
+ pages: Optional[str],
65
+ model: Optional[str],
66
+ html: bool,
67
+ footnotes: bool,
68
+ figures: bool,
69
+ show_images: bool,
70
+ chunk_types: Optional[str],
71
+ chunk_size: Optional[int],
72
+ ) -> dict:
73
+ """Same payload shaping as the MCP extract tool (grouped extensions)."""
74
+ options: dict = {"async": True}
75
+ if pages:
76
+ options["pages"] = pages
77
+ if model:
78
+ options["model"] = model
79
+
80
+ extensions: dict = {}
81
+ if html:
82
+ extensions["alt_outputs"] = {"return_html": True}
83
+ if footnotes:
84
+ extensions["footnote_references"] = True
85
+ chunking: dict = {}
86
+ if chunk_types:
87
+ chunking["chunk_types"] = [c.strip() for c in chunk_types.split(",") if c.strip()]
88
+ if chunk_size:
89
+ chunking["chunk_size"] = chunk_size
90
+ if chunking:
91
+ extensions["chunking"] = chunking
92
+ if extensions:
93
+ options["extensions"] = extensions
94
+
95
+ figure_processing: dict = {}
96
+ if figures:
97
+ figure_processing["description"] = True
98
+ if show_images:
99
+ figure_processing["show_images"] = True
100
+ if figure_processing:
101
+ options["figure_processing"] = figure_processing
102
+ return options
103
+
104
+
105
+ def extract(
106
+ inputs: list[str] = typer.Argument(..., help="File path(s), directory, or URL(s)."),
107
+ pages: Optional[str] = typer.Option(None, help='Page range, 1-indexed, e.g. "1-2,5".'),
108
+ model: Optional[str] = typer.Option(None, help='Model override: "default" or "pulse-ultra-2".'),
109
+ html: bool = typer.Option(False, "--html", help="Also return an HTML representation."),
110
+ footnotes: bool = typer.Option(False, "--footnotes", help="Link footnote markers to text."),
111
+ figures: bool = typer.Option(False, "--figure-descriptions", help="Caption figures/visuals."),
112
+ show_images: bool = typer.Option(False, "--show-images", help="Return image URLs for visuals."),
113
+ chunk_types: Optional[str] = typer.Option(None, "--chunking", help="Comma list: semantic,header,page,recursive."),
114
+ chunk_size: Optional[int] = typer.Option(None, help="Max characters per chunk."),
115
+ schema_arg: Optional[str] = typer.Option(
116
+ None, "--schema",
117
+ help="JSON Schema (file path or inline JSON). Pipelines: extract, then apply the schema for structured output.",
118
+ ),
119
+ output_dir: Optional[Path] = typer.Option(None, "--output", "-o", help="Directory for result files."),
120
+ no_wait: bool = typer.Option(False, "--no-wait", help="Submit and print job_id without polling."),
121
+ json_out: bool = typer.Option(False, "--json", help="Print full JSON to stdout; skip file writing."),
122
+ api_key: Optional[str] = typer.Option(None, "--api-key", envvar="PULSE_API_KEY", help="Override stored key."),
123
+ base_url: Optional[str] = typer.Option(None, "--base-url", help="Engine URL override."),
124
+ ) -> None:
125
+ """Extract markdown (and more) from documents. Writes <name>.pulse.md / .pulse.json."""
126
+ sources = _collect_sources(inputs)
127
+ if not sources:
128
+ raise fail("nothing to extract")
129
+
130
+ schema_obj = _parse_schema_arg(schema_arg) if schema_arg else None
131
+ if schema_obj and no_wait:
132
+ raise fail("--schema needs the extraction result; remove --no-wait")
133
+
134
+ options = _build_options(pages, model, html, footnotes, figures, show_images, chunk_types, chunk_size)
135
+ failures = 0
136
+
137
+ with get_client(api_key, base_url) as client:
138
+ for source in sources:
139
+ label = Path(source).name if not source.startswith("http") else source
140
+ try:
141
+ if source.startswith(("http://", "https://")):
142
+ submit = client.submit_extract({"file_url": source, **options})
143
+ else:
144
+ p = Path(source)
145
+ size = p.stat().st_size
146
+ if size > MAX_UPLOAD_BYTES:
147
+ raise PulseCliError(f"{p.name} is {size / 1e6:.0f} MB — exceeds the 50 MB upload limit.")
148
+ ctype = mimetypes.guess_type(p.name)[0] or "application/octet-stream"
149
+ submit = client.submit_extract_multipart((p.name, p.read_bytes(), ctype), options)
150
+
151
+ job = submit_and_poll(client, submit, label=f"extracting {label}", no_wait=no_wait)
152
+
153
+ if no_wait and job.get("job_id"):
154
+ console.print(f"[body]{label}[/body] [muted]submitted →[/muted] [brand]{job['job_id']}[/brand]")
155
+ continue
156
+ if job.get("status") == "failed":
157
+ raise PulseCliError(job.get("error") or "extraction failed")
158
+
159
+ job = resolve_offloaded_result(client, job)
160
+ result = job.get("result") if isinstance(job.get("result"), dict) else job
161
+ eid = result.get("extraction_id") or job.get("extraction_id")
162
+
163
+ # Optional second pipeline stage: structured extraction (-s).
164
+ structured = None
165
+ if schema_obj is not None:
166
+ if not eid:
167
+ raise PulseCliError("engine returned no extraction_id; cannot apply schema")
168
+ s_submit = client.apply_schema({
169
+ "async": True,
170
+ "extraction_id": eid,
171
+ "schema_config": {"input_schema": schema_obj},
172
+ })
173
+ s_job = submit_and_poll(client, s_submit, label=f"applying schema {label}")
174
+ if s_job.get("status") == "failed":
175
+ raise PulseCliError(s_job.get("error") or "schema extraction failed")
176
+ structured = resolve_offloaded_result(client, s_job)
177
+
178
+ if json_out:
179
+ print_json({"extract": job, "schema": structured} if structured is not None else job)
180
+ else:
181
+ stem = derive_output_stem(source, output_dir)
182
+ written = write_result_files(result, stem)
183
+ if structured is not None:
184
+ s_path = stem.parent / f"{stem.name}.schema.json"
185
+ s_path.write_text(json.dumps(
186
+ structured.get("result") or structured, indent=2, default=str
187
+ ))
188
+ written.append(s_path)
189
+ console.print(
190
+ f"[ok]✓[/ok] [body]{label}[/body] [muted]→[/muted] "
191
+ + ", ".join(str(w) for w in written)
192
+ + (f" [muted]extraction_id=[/muted][brand]{eid}[/brand]" if eid else "")
193
+ )
194
+ except PulseCliError as exc:
195
+ failures += 1
196
+ console.print(f"[err]✗ {label}:[/err] {exc}")
197
+
198
+ if failures:
199
+ raise SystemExit(1)
@@ -0,0 +1,53 @@
1
+ """`pulse jobs <job_id>` — inspect / watch / cancel async jobs."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Optional
5
+
6
+ import typer
7
+
8
+ from pulse_cli.output import console, print_json
9
+ from pulse_cli.runtime import get_client, resolve_offloaded_result, submit_and_poll
10
+ from pulse_cli.status import is_terminal
11
+
12
+ jobs_app = typer.Typer(help="Inspect, watch, and cancel async jobs.")
13
+
14
+
15
+ @jobs_app.command("get")
16
+ def get(
17
+ job_id: str = typer.Argument(..., help="Job id returned by extract/schema/split/tables."),
18
+ watch: bool = typer.Option(False, "--watch", "-w", help="Poll until the job is terminal."),
19
+ json_out: bool = typer.Option(False, "--json"),
20
+ api_key: Optional[str] = typer.Option(None, "--api-key", envvar="PULSE_API_KEY"),
21
+ base_url: Optional[str] = typer.Option(None, "--base-url"),
22
+ ) -> None:
23
+ """Show a job's status (and result when complete)."""
24
+ with get_client(api_key, base_url) as client:
25
+ job = client.get_job(job_id)
26
+ if watch and not is_terminal(job.get("status")):
27
+ job = submit_and_poll(client, {**job, "job_id": job_id}, label="job")
28
+ if is_terminal(job.get("status")):
29
+ job = resolve_offloaded_result(client, job)
30
+
31
+ if json_out:
32
+ print_json(job)
33
+ return
34
+ status = job.get("status", "unknown")
35
+ style = {"completed": "ok", "failed": "err", "canceled": "err", "expired": "err"}.get(status, "muted")
36
+ console.print(f"[muted]job[/muted] [brand]{job_id}[/brand] [muted]status =[/muted] [{style}]{status}[/{style}]")
37
+ print_json(job)
38
+
39
+
40
+ @jobs_app.command("cancel")
41
+ def cancel(
42
+ job_id: str = typer.Argument(...),
43
+ json_out: bool = typer.Option(False, "--json"),
44
+ api_key: Optional[str] = typer.Option(None, "--api-key", envvar="PULSE_API_KEY"),
45
+ base_url: Optional[str] = typer.Option(None, "--base-url"),
46
+ ) -> None:
47
+ """Cancel a pending/processing job (POST /cancel/{job_id})."""
48
+ with get_client(api_key, base_url) as client:
49
+ resp = client.cancel_job(job_id)
50
+ if json_out:
51
+ print_json(resp)
52
+ else:
53
+ console.print(f"[ok]✓[/ok] cancel requested for [brand]{job_id}[/brand]")
@@ -0,0 +1,25 @@
1
+ """`pulse open [extraction_id]` — jump to the Pulse platform in the browser."""
2
+ from __future__ import annotations
3
+
4
+ import webbrowser
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ from pulse_cli.config import PLATFORM_WEB_URL
10
+ from pulse_cli.output import console
11
+
12
+
13
+ def open_platform(
14
+ extraction_id: Optional[str] = typer.Argument(
15
+ None, help="Extraction/job id to view (omit for the dashboard)."
16
+ ),
17
+ ) -> None:
18
+ """Open an extraction (or the dashboard) on platform.runpulse.com."""
19
+ if extraction_id:
20
+ url = f"{PLATFORM_WEB_URL}/dashboard/extractions/{extraction_id}"
21
+ else:
22
+ url = f"{PLATFORM_WEB_URL}/dashboard"
23
+ console.print(f"[muted]opening[/muted] [brand]{url}[/brand]")
24
+ if not webbrowser.open(url):
25
+ console.print("[muted]couldn't launch a browser — open the URL above manually[/muted]")
@@ -0,0 +1,124 @@
1
+ """`pulse schema apply` / `pulse schema generate` — structured extraction tooling."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Optional
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
+
13
+ schema_app = typer.Typer(help="JSON-schema structured extraction (apply / generate).")
14
+
15
+
16
+ def _load_json_file(path: Path, what: str) -> dict:
17
+ try:
18
+ return json.loads(path.read_text())
19
+ except FileNotFoundError:
20
+ raise fail(f"{what} file not found: {path}")
21
+ except json.JSONDecodeError as exc:
22
+ raise fail(f"{what} is not valid JSON ({exc})")
23
+
24
+
25
+ @schema_app.command("apply")
26
+ def apply(
27
+ extraction_id: Optional[str] = typer.Argument(None, help="Extraction to apply the schema to."),
28
+ extraction_ids: Optional[str] = typer.Option(None, "--extraction-ids", help="Comma list to combine several extractions."),
29
+ split_id: Optional[str] = typer.Option(None, "--split-id", help="Apply per-topic schemas to a split."),
30
+ schema_file: Optional[Path] = typer.Option(None, "--schema", help="JSON Schema file."),
31
+ schema_prompt: Optional[str] = typer.Option(None, "--prompt", "-p", help="Extra extraction guidance."),
32
+ schema_config_id: Optional[str] = typer.Option(None, "--schema-config-id", help="Saved schema config id."),
33
+ split_schema_config_file: Optional[Path] = typer.Option(None, "--split-schema-config", help="JSON file: per-topic schema config (with --split-id)."),
34
+ effort: bool = typer.Option(False, "--effort", help="Higher-effort extraction."),
35
+ pages: Optional[str] = typer.Option(None, help='Page range, e.g. "1-3".'),
36
+ output: Optional[Path] = typer.Option(None, "--output", "-o", help="Write result JSON to file."),
37
+ no_wait: bool = typer.Option(False, "--no-wait"),
38
+ json_out: bool = typer.Option(False, "--json"),
39
+ api_key: Optional[str] = typer.Option(None, "--api-key", envvar="PULSE_API_KEY"),
40
+ base_url: Optional[str] = typer.Option(None, "--base-url"),
41
+ ) -> None:
42
+ """Apply a JSON schema to a prior extraction (or split) for structured output."""
43
+ # Mirrors mcp_server/tools/schema.py payload shaping exactly.
44
+ payload: dict = {"async": True}
45
+ if split_id:
46
+ payload["split_id"] = split_id
47
+ if split_schema_config_file:
48
+ payload["split_schema_config"] = _load_json_file(split_schema_config_file, "--split-schema-config")
49
+ else:
50
+ if extraction_ids:
51
+ payload["extraction_ids"] = [e.strip() for e in extraction_ids.split(",") if e.strip()]
52
+ elif extraction_id:
53
+ payload["extraction_id"] = extraction_id
54
+ else:
55
+ raise fail("provide an extraction_id, --extraction-ids, or --split-id")
56
+ if schema_config_id:
57
+ payload["schema_config_id"] = schema_config_id
58
+ else:
59
+ schema_config: dict = {}
60
+ if schema_file:
61
+ schema_config["input_schema"] = _load_json_file(schema_file, "--schema")
62
+ if schema_prompt:
63
+ schema_config["schema_prompt"] = schema_prompt
64
+ if effort:
65
+ schema_config["effort"] = True
66
+ if not schema_config:
67
+ raise fail("provide --schema, --prompt, or --schema-config-id")
68
+ payload["schema_config"] = schema_config
69
+ if pages:
70
+ payload["page_range"] = pages
71
+
72
+ with get_client(api_key, base_url) as client:
73
+ submit = client.apply_schema(payload)
74
+ job = submit_and_poll(client, submit, label="applying schema", no_wait=no_wait)
75
+ if no_wait and job.get("job_id"):
76
+ console.print(f"[muted]submitted →[/muted] [brand]{job['job_id']}[/brand]")
77
+ return
78
+ job = resolve_offloaded_result(client, job)
79
+
80
+ if output:
81
+ output.write_text(json.dumps(job, indent=2, default=str))
82
+ console.print(f"[ok]✓[/ok] wrote {output}")
83
+ elif json_out:
84
+ print_json(job)
85
+ else:
86
+ print_json(job.get("result") or job)
87
+
88
+
89
+ @schema_app.command("generate")
90
+ def generate(
91
+ prompt: Optional[str] = typer.Option(None, "--prompt", "-p", help="Describe the fields you want."),
92
+ from_schema: Optional[Path] = typer.Option(None, "--from", help="Existing schema JSON file to refine."),
93
+ output: Optional[Path] = typer.Option(None, "--output", "-o", help="Write schema to file."),
94
+ api_key: Optional[str] = typer.Option(None, "--api-key", envvar="PULSE_API_KEY"),
95
+ base_url: Optional[str] = typer.Option(None, "--base-url"),
96
+ ) -> None:
97
+ """AI-generate or refine a JSON extraction schema (POST /ai-schema-helper)."""
98
+ if not prompt and not from_schema:
99
+ raise fail("provide --prompt and/or --from")
100
+
101
+ current = json.dumps(_load_json_file(from_schema, "--from")) if from_schema else None
102
+ with get_client(api_key, base_url) as client:
103
+ from pulse_cli.output import is_tty, spinner_status
104
+
105
+ if is_tty():
106
+ with spinner_status("generating schema"):
107
+ resp = client.generate_schema(prompt=prompt, current_schema=current)
108
+ else:
109
+ resp = client.generate_schema(prompt=prompt, current_schema=current)
110
+
111
+ # Engine returns the schema as a JSON string; parse for clean output.
112
+ raw = resp.get("schema") if isinstance(resp, dict) else None
113
+ if isinstance(raw, str):
114
+ try:
115
+ raw = json.loads(raw)
116
+ except json.JSONDecodeError:
117
+ pass
118
+ schema = raw if raw is not None else resp
119
+
120
+ if output:
121
+ output.write_text(json.dumps(schema, indent=2))
122
+ console.print(f"[ok]✓[/ok] wrote {output}")
123
+ else:
124
+ print_json(schema)
@@ -0,0 +1,63 @@
1
+ """`pulse split <extraction_id>` — partition an extraction into topic page-ranges."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Optional
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
+
13
+
14
+ def split(
15
+ extraction_id: str = typer.Argument(..., help="ID of a completed extraction."),
16
+ topic: list[str] = typer.Option(
17
+ [], "--topic", "-t",
18
+ help='Repeatable. "name" or "name:description", e.g. -t "invoice:billing pages".',
19
+ ),
20
+ topics_file: Optional[Path] = typer.Option(None, "--topics-file", help="JSON file: [{name, description}, …]."),
21
+ split_config_id: Optional[str] = typer.Option(None, "--split-config-id", help="Saved split config id."),
22
+ no_wait: bool = typer.Option(False, "--no-wait"),
23
+ json_out: bool = typer.Option(False, "--json"),
24
+ api_key: Optional[str] = typer.Option(None, "--api-key", envvar="PULSE_API_KEY"),
25
+ base_url: Optional[str] = typer.Option(None, "--base-url"),
26
+ ) -> None:
27
+ """Split a prior extraction into topics (run `pulse extract` first)."""
28
+ payload: dict = {"extraction_id": extraction_id, "async": True}
29
+ if split_config_id:
30
+ payload["split_config_id"] = split_config_id
31
+ elif topics_file:
32
+ try:
33
+ payload["split_config"] = {"split_input": json.loads(topics_file.read_text())}
34
+ except (OSError, json.JSONDecodeError) as exc:
35
+ raise fail(f"--topics-file: {exc}")
36
+ elif topic:
37
+ topics = []
38
+ for t in topic:
39
+ name, _, desc = t.partition(":")
40
+ entry: dict = {"name": name.strip()}
41
+ if desc.strip():
42
+ entry["description"] = desc.strip()
43
+ topics.append(entry)
44
+ payload["split_config"] = {"split_input": topics}
45
+ else:
46
+ raise fail("provide --topic (repeatable), --topics-file, or --split-config-id")
47
+
48
+ with get_client(api_key, base_url) as client:
49
+ submit = client.split(payload)
50
+ job = submit_and_poll(client, submit, label="splitting", no_wait=no_wait)
51
+ if no_wait and job.get("job_id"):
52
+ console.print(f"[muted]submitted →[/muted] [brand]{job['job_id']}[/brand]")
53
+ return
54
+ job = resolve_offloaded_result(client, job)
55
+
56
+ if json_out:
57
+ print_json(job)
58
+ return
59
+ result = job.get("result") if isinstance(job.get("result"), dict) else job
60
+ sid = result.get("split_id")
61
+ if sid:
62
+ console.print(f"[muted]split_id =[/muted] [brand]{sid}[/brand]")
63
+ print_json(result.get("split_output") or result)
@@ -0,0 +1,52 @@
1
+ """`pulse tables <extraction_id>` — extract tables from a completed extraction."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ import typer
9
+
10
+ from pulse_cli.output import console, print_json
11
+ from pulse_cli.runtime import get_client, resolve_offloaded_result, submit_and_poll
12
+
13
+
14
+ def tables(
15
+ extraction_id: str = typer.Argument(..., help="ID of a completed extraction."),
16
+ table_format: str = typer.Option("html", "--format", "-f", help='Table output format (e.g. "html", "json").'),
17
+ merge: bool = typer.Option(False, "--merge", help="Merge tables that span pages."),
18
+ charts: bool = typer.Option(False, "--charts-to-tables", help="Convert charts into tables too."),
19
+ output: Optional[Path] = typer.Option(None, "--output", "-o", help="Write result JSON to file."),
20
+ no_wait: bool = typer.Option(False, "--no-wait"),
21
+ json_out: bool = typer.Option(False, "--json"),
22
+ api_key: Optional[str] = typer.Option(None, "--api-key", envvar="PULSE_API_KEY"),
23
+ base_url: Optional[str] = typer.Option(None, "--base-url"),
24
+ ) -> None:
25
+ """Extract tables (run `pulse extract` first to get an extraction_id)."""
26
+ # Format validation is left to the engine (valid set varies by deployment).
27
+ payload = {
28
+ "extraction_id": extraction_id,
29
+ "async": True,
30
+ "tables_config": {
31
+ "merge": merge,
32
+ "table_format": table_format,
33
+ "charts_to_tables": charts,
34
+ },
35
+ }
36
+ with get_client(api_key, base_url) as client:
37
+ submit = client.tables(payload)
38
+ job = submit_and_poll(client, submit, label="extracting tables", no_wait=no_wait)
39
+ jid = job.get("job_id") or job.get("tables_id")
40
+ if no_wait and jid:
41
+ console.print(f"[muted]submitted →[/muted] [brand]{jid}[/brand]")
42
+ return
43
+ job = resolve_offloaded_result(client, job)
44
+
45
+ result = job.get("result") if isinstance(job.get("result"), dict) else job
46
+ if output:
47
+ output.write_text(json.dumps(result, indent=2, default=str))
48
+ console.print(f"[ok]✓[/ok] wrote {output}")
49
+ elif json_out:
50
+ print_json(job)
51
+ else:
52
+ print_json(result.get("tables_output") or result)
@@ -0,0 +1,109 @@
1
+ """`pulse usage` — credit/page usage for the current billing period."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime, timedelta, timezone
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ from pulse_cli import config as cfg
10
+ from pulse_cli.client import PlatformClient
11
+ from pulse_cli.errors import MissingApiKeyError
12
+ from pulse_cli.output import console, fail, is_tty, print_json, spinner_status
13
+
14
+
15
+ def _fmt_int(v: object) -> str:
16
+ return f"{v:,}" if isinstance(v, (int, float)) else "—"
17
+
18
+
19
+ # Bookkeeping rows in by_source that aren't actual document work.
20
+ _NOISE_SOURCES = {"get_job", "delete_job", "large_result_download"}
21
+
22
+
23
+ def usage(
24
+ days: Optional[int] = typer.Option(
25
+ None, "--days", help="Breakdown window in days (default: current billing period)."
26
+ ),
27
+ json_out: bool = typer.Option(False, "--json", help="Print raw JSON."),
28
+ api_key: Optional[str] = typer.Option(None, "--api-key", envvar="PULSE_API_KEY"),
29
+ platform_url: Optional[str] = typer.Option(
30
+ None, "--platform-url", envvar="PULSE_PLATFORM_URL", help="Platform API URL override."
31
+ ),
32
+ ) -> None:
33
+ """Show plan, pages used vs allowance, and a usage breakdown by source."""
34
+ conf = cfg.load(api_key_override=api_key)
35
+ if not conf.api_key:
36
+ raise MissingApiKeyError()
37
+ url = platform_url or conf.platform_url
38
+ if not url:
39
+ raise fail(
40
+ "No platform URL configured. Re-run login with it, e.g.\n"
41
+ " pulse login --platform-url https://platform.runpulse.com"
42
+ )
43
+
44
+ start = end = None
45
+ if days:
46
+ now = datetime.now(timezone.utc)
47
+ start, end = (now - timedelta(days=days)).strftime("%Y-%m-%d"), now.strftime("%Y-%m-%d")
48
+
49
+ with PlatformClient(conf.api_key, url) as pc:
50
+ if is_tty() and not json_out:
51
+ with spinner_status("fetching usage"):
52
+ period = pc.current_period_usage()
53
+ breakdown = pc.usage_breakdown(start, end)
54
+ else:
55
+ period = pc.current_period_usage()
56
+ breakdown = pc.usage_breakdown(start, end)
57
+
58
+ if json_out:
59
+ print_json({"current_period": period, "breakdown": breakdown})
60
+ return
61
+
62
+ plan = period.get("plan_name") or "—"
63
+ label = period.get("period_label") or period.get("period_type") or ""
64
+ used = period.get("pages_used")
65
+ allowance = period.get("page_allowance")
66
+ pct = period.get("usage_percentage")
67
+
68
+ console.print(f"\n[heading]plan[/heading] [body]{plan}[/body] [muted]{label}[/muted]")
69
+ if period.get("is_metered"):
70
+ rate = period.get("metered_rate")
71
+ console.print(
72
+ f"[heading]pages[/heading] [body]{_fmt_int(used)}[/body] "
73
+ f"[muted]metered{f' @ ${rate}/page' if rate else ''}[/muted]"
74
+ )
75
+ elif period.get("is_unlimited") or allowance is None:
76
+ console.print(f"[heading]pages[/heading] [body]{_fmt_int(used)}[/body] [muted]/ unlimited[/muted]")
77
+ else:
78
+ remaining = period.get("pages_remaining")
79
+ console.print(
80
+ f"[heading]pages[/heading] [body]{_fmt_int(used)}[/body] [muted]/[/muted] "
81
+ f"[body]{_fmt_int(allowance)}[/body]"
82
+ + (f" [muted]({pct:.1f}% used, {_fmt_int(remaining)} left)[/muted]" if pct is not None else "")
83
+ )
84
+
85
+ by_source = {
86
+ k: v for k, v in (breakdown.get("by_source") or {}).items() if k not in _NOISE_SOURCES
87
+ }
88
+ # Recompute totals from real work only (server totals include polling noise).
89
+ if by_source:
90
+ docs = sum(v.get("documents") or 0 for v in by_source.values())
91
+ pgs = sum(v.get("pages") or 0 for v in by_source.values())
92
+ else:
93
+ docs, pgs = breakdown.get("total_documents"), breakdown.get("total_pages")
94
+
95
+ window = f"last {days} days" if days else "this period"
96
+ console.print(
97
+ f"\n[heading]activity[/heading] [muted]{window}[/muted] "
98
+ f"[body]{_fmt_int(docs)}[/body] [muted]requests ·[/muted] "
99
+ f"[body]{_fmt_int(pgs)}[/body] [muted]pages[/muted]"
100
+ )
101
+
102
+ if by_source:
103
+ console.print("\n[heading]by source[/heading]")
104
+ for src, row in sorted(by_source.items(), key=lambda kv: -(kv[1].get("pages") or 0)):
105
+ console.print(
106
+ f" [body]{src:<14}[/body] [muted]{_fmt_int(row.get('documents'))} docs ·[/muted] "
107
+ f"[body]{_fmt_int(row.get('pages'))}[/body] [muted]pages[/muted]"
108
+ )
109
+ console.print()