reducto-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,152 @@
1
+ # Reducto CLI
2
+
3
+ Welcome to the Reducto CLI. This tool lets you parse documents, extract structured data, and modify documents using Reducto’s platform.
4
+
5
+ ## Usage
6
+
7
+ - Both commands accept a single file or a directory. Directories are scanned recursively and only supported files (below) are processed.
8
+
9
+ ### Examples
10
+
11
+ - Parse a single file: `reducto parse path/to/document.pdf`
12
+ - Parse an entire folder: `reducto parse ./docs`
13
+ - Extract with a schema (path or inline JSON): `reducto extract ./docs/invoice.pdf -s schemas/invoice.json`
14
+
15
+ Parsed outputs are written as `<filename>.parse.md` with YAML front matter. Extraction reuses existing parses when possible and saves `<filename>.extract.json` containing only the payload.
16
+
17
+ ## Supported File Types
18
+
19
+ • PDF: `.pdf`
20
+ • Images: `.png`, `.jpg`, `.jpeg`
21
+ • Office documents: `.doc`, `.docx`, `.ppt`, `.pptx`
22
+ • Spreadsheets: `.xls`, `.xlsx`
23
+
24
+ Commands accept either a file or a directory. Directories are scanned recursively, and only the supported file types listed above are processed.
25
+
26
+ ## Parse Command Options
27
+
28
+ The `parse` command supports several flags to customize parsing behavior:
29
+
30
+ ### Flags
31
+
32
+ | Flag | Description |
33
+ |------|-------------|
34
+ | `--agentic` | Enables all agentic options for tables, text, and figures. Increases accuracy but also increases latency. Use when document quality or complex layouts require enhanced processing. |
35
+ | `--change-tracking` | Enables change tracking during parsing. Returns `<s>` tags around strikethrough text, `<u>` tags around underlined text, and `<change>` tags around colored adjacent strikethrough and underlined text. Useful for documents with revision history. |
36
+ | `--highlights` | Include highlighted text in the parsed output. |
37
+ | `--hyperlinks` | Include embedded hyperlinks in the parsed output. |
38
+ | `--comments` | Include document comments in the parsed output. |
39
+
40
+ ### Examples
41
+
42
+ ```bash
43
+ # Basic parse
44
+ reducto parse document.pdf
45
+
46
+ # Parse with maximum accuracy (slower)
47
+ reducto parse document.pdf --agentic
48
+
49
+ # Parse a contract with change tracking
50
+ reducto parse contract.pdf --change-tracking
51
+
52
+ # Parse with all metadata
53
+ reducto parse document.pdf --hyperlinks --comments --highlights
54
+
55
+ # Combine flags as needed
56
+ reducto parse legal_doc.pdf --agentic --change-tracking --comments
57
+ ```
58
+
59
+ ## Extract Command Overview
60
+
61
+ The `extract` command enables you to pull specific, structured data from your documents according to a schema you provide (using JSON Schema). It is designed to automate information extraction by mapping complex or unstructured documents—such as invoices, receipts, reports, forms, contracts, financial statements, or tables—into machine-readable JSON.
62
+
63
+ Common use cases include:
64
+ - Extracting line items, totals, vendor/customer info from invoices and receipts
65
+ - Pulling key fields, tables, or sections from contracts or legal documents
66
+ - Capturing form field values from scanned forms or applications
67
+ - Summarizing structured results from reports, statements, or medical records
68
+
69
+ By providing a schema, you ensure consistency and determinism, so the extracted JSON conforms exactly to your business requirements. This is especially valuable for automating downstream processing pipelines, integrating with databases, or feeding data to other tools.
70
+
71
+ You can perform extraction on individual files or batches (folders), and extracted payloads are saved as `<filename>.extract.json`.
72
+
73
+ ## Schema Guidelines for `reducto extract`
74
+
75
+ • Schemas must be valid JSON Schema documents.
76
+ • The top-level schema **must** be an object (`{"type": "object", ...}`) — inline strings or arrays are not permitted.
77
+ • Provide explicit property definitions so the extractor can map fields deterministically.
78
+ • Schemas may be supplied as file paths or inline JSON strings.
79
+
80
+ ### Example Schema
81
+
82
+ ```json
83
+ {
84
+ "type": "object",
85
+ "properties": {
86
+ "items": {
87
+ "type": "array",
88
+ "items": {
89
+ "type": "object",
90
+ "properties": {
91
+ "article_number": {"type": "string"},
92
+ "description": {"type": "string"},
93
+ "quantity": {"type": "number"},
94
+ "unit_price": {"type": "number"},
95
+ "total_price": {"type": "number"}
96
+ },
97
+ "required": [
98
+ "article_number",
99
+ "description",
100
+ "quantity",
101
+ "unit_price",
102
+ "total_price"
103
+ ]
104
+ }
105
+ }
106
+ },
107
+ "required": ["items"]
108
+ }
109
+ ```
110
+
111
+ You can reuse parses across multiple extractions: the CLI automatically detects existing `.parse.md` files, rehydrates the recorded job ID, and uses `jobid://<id>` references to accelerate extraction jobs.
112
+
113
+ ## Editing Documents with `reducto edit`
114
+
115
+ The `edit` command allows you to modify documents using natural language instructions. It uploads the document, applies the specified edits, and downloads the resulting file.
116
+
117
+ ### Usage
118
+
119
+ ```bash
120
+ reducto edit path/to/document.pdf --instructions "Your editing instructions here"
121
+ reducto edit path/to/document.pdf -i "Your editing instructions here"
122
+ ```
123
+
124
+ ### Parameters
125
+
126
+ | Parameter | Required | Description |
127
+ |-----------|----------|-------------|
128
+ | `path` | Yes | Path to a file or directory. Directories are scanned recursively for supported file types. |
129
+ | `--instructions`, `-i` | Yes | Natural language instructions describing the edits to apply. |
130
+
131
+ ### Output
132
+
133
+ Edited files are saved alongside the original with the naming pattern `<filename>.edited.<extension>`. For example:
134
+ - `invoice.pdf` → `invoice.edited.pdf`
135
+ - `report.docx` → `report.edited.docx`
136
+
137
+ ### Examples
138
+
139
+ ```bash
140
+ reducto edit contract.pdf -i "Fill in the client name as 'Acme Corporation' and set the contract date to January 15, 2024"
141
+
142
+ reducto edit document.pdf -i "Fill out the form with: Name: John Doe, Email: john@example.com, Select 'Yes' for newsletter subscription"
143
+ ```
144
+
145
+ ### Effective Instructions
146
+
147
+ For best results with the `--instructions` flag:
148
+ - Be specific about what content to modify and how
149
+ - Reference specific elements (headers, footers, tables, specific text)
150
+ - Describe the desired outcome clearly
151
+ - For bulk operations on directories, ensure instructions apply uniformly to all file types
152
+
@@ -0,0 +1,10 @@
1
+ """Utilities for the reducto CLI."""
2
+
3
+ __all__ = [
4
+ "config",
5
+ "files",
6
+ "parser",
7
+ "extractor",
8
+ "schema",
9
+ "help_text",
10
+ ]
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
reducto_cli/auth.py ADDED
@@ -0,0 +1,173 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import platform
5
+ import time
6
+ import webbrowser
7
+ import asyncio
8
+
9
+ import httpx
10
+ import typer
11
+ from .config import _write_api_key, _read_saved_api_key
12
+
13
+
14
+ class AuthSpinner:
15
+ """Simple loading spinner for authentication polling."""
16
+
17
+ def __init__(self):
18
+ self._spinner_chars = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
19
+ self._running = False
20
+ self._task = None
21
+
22
+ async def start(self, message: str = "Waiting for authentication"):
23
+ """Start the spinner."""
24
+ self._running = True
25
+ self._task = asyncio.create_task(self._spin(message))
26
+
27
+ async def stop(self):
28
+ """Stop the spinner."""
29
+ self._running = False
30
+ if self._task:
31
+ self._task.cancel()
32
+ try:
33
+ await self._task
34
+ except asyncio.CancelledError:
35
+ pass
36
+
37
+ async def _spin(self, message: str):
38
+ """Spinner animation loop."""
39
+ i = 0
40
+ while self._running:
41
+ char = self._spinner_chars[i % len(self._spinner_chars)]
42
+ print(f"\r{char} {message}...", end="", flush=True)
43
+ i += 1
44
+ await asyncio.sleep(0.1)
45
+ print("\r" + " " * (len(message) + 10), end="", flush=True) # Clear line
46
+
47
+
48
+ async def authenticate_with_device_code() -> None:
49
+ """Authenticate with Reducto via device code flow."""
50
+
51
+ # Check if API key already exists
52
+ existing_key = _read_saved_api_key()
53
+ if existing_key:
54
+ if not typer.confirm("An API key is already set. Do you want to replace it?"):
55
+ typer.echo("Authentication cancelled.")
56
+ return
57
+
58
+ client_info = {
59
+ "hostname": platform.node(),
60
+ "os": f"{platform.system()} {platform.release()}",
61
+ "cli_version": "1.0.0",
62
+ }
63
+
64
+ async with httpx.AsyncClient() as client:
65
+ studio_api_url = os.getenv(
66
+ "REDUCTO_STUDIO_API_URL",
67
+ "https://mild-moose-423.convex.site",
68
+ )
69
+
70
+ try:
71
+ # Request device code
72
+ response = await client.post(
73
+ f"{studio_api_url}/deviceAuth/deviceCode",
74
+ json={"clientInfo": client_info},
75
+ timeout=10.0,
76
+ )
77
+ response.raise_for_status()
78
+ auth_data = response.json()
79
+
80
+ device_code = auth_data["device_code"]
81
+ user_code = auth_data["user_code"]
82
+ verification_uri = auth_data["verification_uri_complete"]
83
+ interval = auth_data["interval"]
84
+ expires_in = auth_data["expires_in"]
85
+
86
+ # Show instructions to user
87
+ typer.echo("\nReducto CLI Authentication")
88
+ typer.echo(f"Your code: {typer.style(user_code, fg='cyan', bold=True)}")
89
+ typer.echo(f"Visit: {typer.style(verification_uri, fg='blue')}")
90
+ typer.echo(f"Code expires in {expires_in} seconds")
91
+
92
+ # Try to open browser automatically
93
+ try:
94
+ webbrowser.open(verification_uri)
95
+ typer.echo("Browser opened automatically.", err=True)
96
+ except Exception:
97
+ typer.echo("Could not open browser automatically.", err=True)
98
+
99
+ # Start spinner and poll for approval
100
+ spinner = AuthSpinner()
101
+ await spinner.start()
102
+
103
+ start_time = time.time()
104
+ try:
105
+ while time.time() - start_time < expires_in:
106
+ await asyncio.sleep(interval)
107
+
108
+ try:
109
+ poll_response = await client.post(
110
+ f"{studio_api_url}/deviceAuth/poll",
111
+ json={"device_code": device_code},
112
+ timeout=10.0,
113
+ )
114
+ poll_response.raise_for_status()
115
+ poll_data = poll_response.json()
116
+
117
+ if poll_data["status"] == "approved":
118
+ api_key = poll_data["api_key"]
119
+ _write_api_key(api_key)
120
+ await spinner.stop()
121
+ typer.echo(
122
+ f"{typer.style('✓ Authentication successful!', fg='green', bold=True)}"
123
+ )
124
+ typer.echo("API key saved to ~/.reducto/config.yaml")
125
+ return
126
+
127
+ elif poll_data["status"] == "denied":
128
+ await spinner.stop()
129
+ typer.echo(
130
+ f"{typer.style('✗ Authentication denied.', fg='red', bold=True)}"
131
+ )
132
+ raise typer.Exit(1)
133
+
134
+ elif poll_data["status"] == "expired":
135
+ await spinner.stop()
136
+ typer.echo(
137
+ f"{typer.style('✗ Authentication expired.', fg='red', bold=True)}"
138
+ )
139
+ typer.echo("Please try again.")
140
+ raise typer.Exit(1)
141
+
142
+ # Still pending, continue polling
143
+
144
+ except httpx.HTTPStatusError as e:
145
+ if e.response.status_code == 400:
146
+ error_data = e.response.json()
147
+ if error_data.get("error") == "slow_down":
148
+ await asyncio.sleep(interval)
149
+ continue
150
+ await spinner.stop()
151
+ typer.echo(f"{typer.style('✗ Polling failed:', fg='red')} {e}")
152
+ raise typer.Exit(1)
153
+
154
+ # If we get here, the session expired
155
+ await spinner.stop()
156
+ typer.echo(
157
+ f"{typer.style('✗ Authentication timed out.', fg='red', bold=True)}"
158
+ )
159
+ typer.echo("Please try again.")
160
+ raise typer.Exit(1)
161
+
162
+ except Exception:
163
+ await spinner.stop()
164
+ raise
165
+
166
+ except httpx.HTTPStatusError as e:
167
+ typer.echo(f"{typer.style('✗ Authentication failed:', fg='red')} {e}")
168
+ if e.response.status_code == 500:
169
+ typer.echo("Server error. Please try again later.")
170
+ raise typer.Exit(1)
171
+ except Exception as e:
172
+ typer.echo(f"{typer.style('✗ Authentication failed:', fg='red')} {e}")
173
+ raise typer.Exit(1)
reducto_cli/cli.py ADDED
@@ -0,0 +1,81 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import typer
6
+ from async_typer import AsyncTyper, Option
7
+ from reducto import AsyncReducto
8
+
9
+ from .config import get_api_key
10
+ from .extractor import extract_files
11
+ from .editor import edit_files
12
+ from .files import collect_files
13
+ from .help_text import load_help_text
14
+ from .parser import parse_files
15
+ from .schema import load_schema
16
+ from .auth import authenticate_with_device_code
17
+
18
+
19
+ app = AsyncTyper(help=load_help_text())
20
+
21
+
22
+ @app.async_command()
23
+ async def login() -> None:
24
+ """Authenticate with Reducto via device code flow."""
25
+ await authenticate_with_device_code()
26
+
27
+
28
+ @app.async_command()
29
+ async def parse(
30
+ path: Path,
31
+ agentic: bool = Option(False, "--agentic", help="Enables all agentic options. Increases latency and accuracy"),
32
+ change_tracking: bool = Option(False, "--change-tracking", help="Enables change tracking during parsing. Returns <s> tags around strikethrough text, <u> tags around underlined text, and <change> tags around colored adjacent strikethough and underlined text"),
33
+ highlights: bool = Option(False, "--highlights", help="Highlight text in the parsed document"),
34
+ hyperlinks: bool = Option(False, "--hyperlinks", help="Include embedded hyperlinks"),
35
+ comments: bool = Option(False, "--comments", help="Include comments"),
36
+ ) -> None:
37
+ files = collect_files(path)
38
+ if not files:
39
+ typer.echo("No files found to parse.", err=True)
40
+ raise typer.Exit(1)
41
+
42
+ include = [
43
+ option for flag, option in [
44
+ (change_tracking, "change_tracking"),
45
+ (highlights, "highlight"),
46
+ (hyperlinks, "hyperlinks"),
47
+ (comments, "comments"),
48
+ ] if flag
49
+ ]
50
+
51
+ api_key = get_api_key()
52
+ async with AsyncReducto(api_key=api_key) as client:
53
+ await parse_files(client, files, agentic, include)
54
+
55
+
56
+ @app.async_command()
57
+ async def extract(path: Path, schema: str = Option(..., "--schema", "-s")) -> None:
58
+ files = collect_files(path)
59
+ if not files:
60
+ typer.echo("No files found to extract.", err=True)
61
+ raise typer.Exit(1)
62
+
63
+ schema_value = load_schema(schema)
64
+ api_key = get_api_key()
65
+ async with AsyncReducto(api_key=api_key) as client:
66
+ await extract_files(client, files, schema_value)
67
+
68
+
69
+ @app.async_command()
70
+ async def edit(path: Path, instructions: str = Option(..., "--instructions", "-i")) -> None:
71
+ files = collect_files(path)
72
+ if not files:
73
+ typer.echo("No files found to edit.", err=True)
74
+ raise typer.Exit(1)
75
+
76
+ api_key = get_api_key()
77
+ async with AsyncReducto(api_key=api_key) as client:
78
+ await edit_files(client, files, instructions)
79
+
80
+ def main() -> None:
81
+ app()
reducto_cli/config.py ADDED
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ import typer
7
+
8
+
9
+ CONFIG_PATH = Path.home() / ".reducto" / "config.yaml"
10
+
11
+
12
+ def _read_saved_api_key() -> str | None:
13
+ try:
14
+ content = CONFIG_PATH.read_text(encoding="utf-8")
15
+ except FileNotFoundError:
16
+ return None
17
+ except OSError:
18
+ return None
19
+
20
+ for line in content.splitlines():
21
+ if ":" not in line:
22
+ continue
23
+ key, value = line.split(":", 1)
24
+ if key.strip().lower() == "api_key":
25
+ return value.strip().strip("\"'")
26
+ return None
27
+
28
+
29
+ def _write_api_key(value: str) -> None:
30
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
31
+ CONFIG_PATH.write_text(f"api_key: {value.strip()}\n", encoding="utf-8")
32
+ try:
33
+ os.chmod(CONFIG_PATH, 0o600)
34
+ except OSError:
35
+ pass
36
+
37
+
38
+ def get_api_key() -> str:
39
+ env_value = os.getenv("REDUCTO_API_KEY")
40
+ if env_value:
41
+ return env_value.strip()
42
+
43
+ saved_value = _read_saved_api_key()
44
+ if saved_value:
45
+ os.environ["REDUCTO_API_KEY"] = saved_value
46
+ return saved_value
47
+
48
+ # Suggest running login command first
49
+ typer.echo("\nNo API key found. You can authenticate by running:")
50
+ typer.echo(" " + typer.style("reducto login", fg="cyan", bold=True))
51
+ typer.echo("\nThis will open a browser to authenticate your CLI.")
52
+
53
+ # Still allow manual entry as fallback
54
+ if typer.confirm("\nOr enter an API key manually?"):
55
+ entered_value = typer.prompt(
56
+ "Enter your Reducto API key", hide_input=True
57
+ ).strip()
58
+ if not entered_value:
59
+ typer.echo("An API key is required to continue.", err=True)
60
+ raise typer.Exit(1)
61
+
62
+ _write_api_key(entered_value)
63
+ os.environ["REDUCTO_API_KEY"] = entered_value
64
+ return entered_value
65
+ else:
66
+ typer.echo("\nPlease run 'reducto login' to authenticate.", err=True)
67
+ raise typer.Exit(1)
reducto_cli/editor.py ADDED
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from collections.abc import Iterable
5
+ from pathlib import Path
6
+
7
+ import httpx
8
+ import typer
9
+ from reducto import AsyncReducto, ReductoError
10
+
11
+ from .files import edit_output_path
12
+
13
+
14
+ async def edit_files(client: AsyncReducto, files: Iterable[Path], instructions: str) -> None:
15
+ tasks = [asyncio.create_task(edit_file(client, file_path, instructions)) for file_path in files]
16
+ for task in asyncio.as_completed(tasks):
17
+ destination = await task
18
+ if destination is not None:
19
+ typer.echo(f"Saved {destination}")
20
+
21
+
22
+ async def edit_file(client: AsyncReducto, file_path: Path, instructions: str) -> Path | None:
23
+ try:
24
+ upload = await client.upload(file=file_path)
25
+ except ReductoError as exc:
26
+ typer.echo(f"Failed to upload {file_path}: {exc}", err=True)
27
+ return None
28
+
29
+ try:
30
+ response = await client.edit.run(
31
+ document_url=upload.file_id,
32
+ edit_instructions=instructions,
33
+ )
34
+ except ReductoError as exc:
35
+ typer.echo(f"Failed to edit {file_path}: {exc}", err=True)
36
+ return None
37
+
38
+ document_url = getattr(response, "document_url", None)
39
+ if not document_url:
40
+ typer.echo(f"No document URL in response for {file_path}", err=True)
41
+ return None
42
+
43
+ destination = edit_output_path(file_path)
44
+ try:
45
+ await _download_file(document_url, destination)
46
+ except Exception as exc:
47
+ typer.echo(f"Failed to download edited document for {file_path}: {exc}", err=True)
48
+ return None
49
+
50
+ return destination
51
+
52
+
53
+ async def _download_file(url: str, destination: Path) -> None:
54
+ async with httpx.AsyncClient() as http_client:
55
+ async with http_client.stream("GET", url) as response:
56
+ response.raise_for_status()
57
+ destination.parent.mkdir(parents=True, exist_ok=True)
58
+ with destination.open("wb") as f:
59
+ async for chunk in response.aiter_bytes():
60
+ f.write(chunk)
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any, Iterable
7
+
8
+ import typer
9
+ from reducto import AsyncReducto, ReductoError
10
+
11
+ from .files import extract_output_path, parse_output_path
12
+ from .parser import ParseInfo, parse_file, read_parse_metadata
13
+
14
+
15
+ async def extract_files(client: AsyncReducto, files: Iterable[Path], schema: object) -> None:
16
+ tasks = [asyncio.create_task(_extract_file(client, file_path, schema)) for file_path in files]
17
+ for task in asyncio.as_completed(tasks):
18
+ destination = await task
19
+ if destination is not None:
20
+ typer.echo(f"Saved {destination}")
21
+
22
+
23
+ async def _extract_file(client: AsyncReducto, file_path: Path, schema: object) -> Path | None:
24
+ job_id = _existing_job_id(file_path)
25
+ parse_info: ParseInfo | None = None
26
+
27
+ if job_id is None:
28
+ parse_info = await parse_file(client, file_path)
29
+ if parse_info is None:
30
+ return None
31
+ job_id = parse_info.job_id
32
+
33
+ input_payload: object
34
+ if job_id and job_id != "unknown":
35
+ input_payload = f"jobid://{job_id}"
36
+ else:
37
+ try:
38
+ upload = await client.upload(file=file_path)
39
+ except ReductoError as exc:
40
+ typer.echo(f"Failed to upload {file_path}: {exc}", err=True)
41
+ return None
42
+ input_payload = {"file_id": upload.file_id}
43
+
44
+ try:
45
+ response = await client.extract.run(
46
+ input=input_payload,
47
+ instructions={"schema": schema},
48
+ )
49
+ except ReductoError as exc:
50
+ typer.echo(f"Failed to extract {file_path}: {exc}", err=True)
51
+ return None
52
+
53
+ content = _response_to_text(response)
54
+
55
+ destination = extract_output_path(file_path)
56
+ destination.write_text(json.dumps(content, indent=2, ensure_ascii=False), encoding="utf-8")
57
+ return destination
58
+
59
+
60
+ def _existing_job_id(source: Path) -> str | None:
61
+ metadata = read_parse_metadata(source)
62
+ job_id = metadata.get("job_id")
63
+ return job_id if job_id else None
64
+ def _response_to_text(response: object) -> Any:
65
+ if hasattr(response, "model_dump"):
66
+ data = response.model_dump()
67
+ cleaned = _strip_metadata(data)
68
+ return _unwrap_single_result(cleaned)
69
+ return response
70
+
71
+
72
+ def _strip_metadata(data: Any) -> Any:
73
+ if isinstance(data, dict):
74
+ return {
75
+ key: _strip_metadata(value)
76
+ for key, value in data.items()
77
+ if key not in {"usage", "job_id", "studio_link", "citations"}
78
+ }
79
+ if isinstance(data, list):
80
+ return [_strip_metadata(item) for item in data]
81
+ return data
82
+
83
+
84
+ def _unwrap_single_result(data: Any) -> Any:
85
+ if isinstance(data, dict) and "result" in data and len(data) == 1:
86
+ return _unwrap_single_result(data["result"])
87
+ if isinstance(data, list) and len(data) == 1:
88
+ return _unwrap_single_result(data[0])
89
+ return data
reducto_cli/files.py ADDED
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Iterable
5
+
6
+ import typer
7
+
8
+
9
+ PARSE_SUFFIX = ".parse.md"
10
+ EXTRACT_SUFFIX = ".extract.json"
11
+ EDIT_SUFFIX = ".edited"
12
+ GENERATED_SUFFIXES = (PARSE_SUFFIX, EXTRACT_SUFFIX)
13
+ SUPPORTED_EXTENSIONS = {".pdf", ".png", ".jpg", ".jpeg", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx"}
14
+
15
+
16
+ def collect_files(target: Path) -> list[Path]:
17
+ path = target.expanduser()
18
+ if not path.exists():
19
+ raise typer.BadParameter(f"{path} does not exist")
20
+
21
+ if path.is_file():
22
+ return [path] if _should_process(path) else []
23
+
24
+ return sorted(p for p in _iter_files(path) if _should_process(p))
25
+
26
+
27
+ def _iter_files(directory: Path) -> Iterable[Path]:
28
+ for item in directory.rglob("*"):
29
+ if item.is_file():
30
+ yield item
31
+
32
+
33
+ def is_generated(path: Path) -> bool:
34
+ return any(path.name.endswith(suffix) for suffix in GENERATED_SUFFIXES)
35
+
36
+
37
+ def _should_process(path: Path) -> bool:
38
+ if is_generated(path):
39
+ return False
40
+ return path.suffix.lower() in SUPPORTED_EXTENSIONS
41
+
42
+
43
+ def parse_output_path(source: Path) -> Path:
44
+ return source.with_name(f"{source.name}{PARSE_SUFFIX}")
45
+
46
+
47
+ def extract_output_path(source: Path) -> Path:
48
+ return source.with_name(f"{source.name}{EXTRACT_SUFFIX}")
49
+
50
+
51
+ def edit_output_path(source: Path) -> Path:
52
+ return source.with_name(f"{source.stem}{EDIT_SUFFIX}{source.suffix}")
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib import resources
4
+ from pathlib import Path
5
+
6
+
7
+ def load_help_text() -> str:
8
+ try:
9
+ package_path = resources.files("reducto_cli").joinpath("INSTRUCTIONS.md")
10
+ content = package_path.read_text(encoding="utf-8").strip()
11
+ if content:
12
+ return content
13
+ except (FileNotFoundError, OSError, AttributeError):
14
+ pass
15
+
16
+ fallback_path = Path(__file__).resolve().parent / "INSTRUCTIONS.md"
17
+ try:
18
+ content = fallback_path.read_text(encoding="utf-8").strip()
19
+ if content:
20
+ return content
21
+ except OSError:
22
+ pass
23
+
24
+ return "Reducto CLI"
reducto_cli/parser.py ADDED
@@ -0,0 +1,89 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Iterable, Optional
7
+
8
+ import typer
9
+ from reducto import AsyncReducto, ReductoError
10
+ from reducto.types.shared.parse_response import ResultFullResult
11
+
12
+ from .files import parse_output_path
13
+
14
+
15
+ @dataclass
16
+ class ParseInfo:
17
+ job_id: str
18
+ duration: Optional[float]
19
+ output_path: Path
20
+
21
+
22
+ async def parse_files(client: AsyncReducto, files: Iterable[Path], agentic: bool, formatting_include: list[str]) -> None:
23
+ tasks = [asyncio.create_task(parse_file(client, file_path, agentic, formatting_include)) for file_path in files]
24
+ for task in asyncio.as_completed(tasks):
25
+ info = await task
26
+ if info is not None:
27
+ typer.echo(f"Saved {info.output_path}")
28
+
29
+
30
+ async def parse_file(client: AsyncReducto, file_path: Path, agentic: bool, formatting_include: list[str]) -> ParseInfo | None:
31
+ try:
32
+ upload = await client.upload(file=file_path)
33
+ kwargs = {"input": {"file_id": upload.file_id}, "formatting": {"include": formatting_include}}
34
+ if agentic:
35
+ kwargs["enhance"] = {"agentic": [{"scope": "table"}, {"scope": "text"}, {"scope": "figure"}]}
36
+ response = await client.parse.run(**kwargs)
37
+ except ReductoError as exc:
38
+ typer.echo(f"Failed to parse {file_path}: {exc}", err=True)
39
+ return None
40
+
41
+ text = _extract_text(response.result) if hasattr(response, "result") else None
42
+ if text is None:
43
+ typer.echo(f"No textual content available for {file_path}", err=True)
44
+ return None
45
+
46
+ job_id = getattr(response, "job_id", "unknown") or "unknown"
47
+ duration = getattr(response, "duration", None)
48
+ front_matter = [
49
+ "---",
50
+ f"job_id: {job_id}",
51
+ f"duration: {duration if duration is not None else 'null'}",
52
+ "---",
53
+ "",
54
+ ]
55
+
56
+ destination = parse_output_path(file_path)
57
+ destination.write_text("\n".join(front_matter + [text]), encoding="utf-8")
58
+ return ParseInfo(job_id=job_id, duration=duration, output_path=destination)
59
+
60
+
61
+ def _extract_text(result: object) -> Optional[str]:
62
+ if isinstance(result, ResultFullResult) and result.chunks:
63
+ return result.chunks[0].content
64
+ return None
65
+
66
+
67
+ def read_parse_metadata(source: Path) -> dict[str, str]:
68
+ path = parse_output_path(source)
69
+ if not path.exists():
70
+ return {}
71
+ try:
72
+ text = path.read_text(encoding="utf-8")
73
+ except OSError:
74
+ return {}
75
+
76
+ lines = text.splitlines()
77
+ if not lines or lines[0].strip() != "---":
78
+ return {}
79
+
80
+ data: dict[str, str] = {}
81
+ for line in lines[1:]:
82
+ stripped = line.strip()
83
+ if stripped == "---":
84
+ break
85
+ if ":" not in line:
86
+ continue
87
+ key, value = line.split(":", 1)
88
+ data[key.strip()] = value.strip()
89
+ return data
reducto_cli/schema.py ADDED
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import typer
7
+
8
+
9
+ def load_schema(value: str) -> object:
10
+ candidate = Path(value).expanduser()
11
+ if candidate.exists():
12
+ try:
13
+ raw = candidate.read_text(encoding="utf-8")
14
+ except OSError as exc:
15
+ raise typer.BadParameter(f"Unable to read schema file: {exc}") from exc
16
+ else:
17
+ raw = value
18
+
19
+ raw = raw.strip()
20
+ if not raw:
21
+ raise typer.BadParameter("Schema cannot be empty")
22
+
23
+ try:
24
+ return json.loads(raw)
25
+ except json.JSONDecodeError:
26
+ return raw
@@ -0,0 +1,175 @@
1
+ Metadata-Version: 2.4
2
+ Name: reducto-cli
3
+ Version: 0.1.0
4
+ Summary: CLI for Reducto document processing
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: async-typer>=0.1.10
8
+ Requires-Dist: reductoai>=0.13.0
9
+ Requires-Dist: typer>=0.20.0
10
+
11
+ # Reducto CLI
12
+
13
+ Welcome to the Reducto CLI. This tool lets you parse documents, extract structured data, and modify documents using Reducto’s platform.
14
+
15
+ ## Installation
16
+
17
+ Install the Reducto CLI using pip:
18
+
19
+ ```bash
20
+ pip install reducto-cli
21
+ ```
22
+
23
+ ## Authentication
24
+
25
+ Before using the CLI, authenticate with your Reducto API key:
26
+
27
+ ```bash
28
+ reducto login
29
+ ```
30
+
31
+ ### Examples
32
+
33
+ - Parse a single file: `reducto parse path/to/document.pdf`
34
+ - Parse an entire folder: `reducto parse ./docs`
35
+ - Extract with a schema (path or inline JSON): `reducto extract ./docs/invoice.pdf -s schemas/invoice.json`
36
+ - Edit a single file: `reducto edit path/to/document.pdf --instructions "Your editing instructions here"`
37
+
38
+ Parsed outputs are written as `<filename>.parse.md`. Extraction reuses existing parses when possible and saves `<filename>.extract.json` containing only the payload.
39
+
40
+ ## Supported File Types
41
+
42
+ • PDF: `.pdf`
43
+ • Images: `.png`, `.jpg`, `.jpeg`
44
+ • Office documents: `.doc`, `.docx`, `.ppt`, `.pptx`
45
+ • Spreadsheets: `.xls`, `.xlsx`
46
+
47
+ Commands accept either a file or a directory. Directories are scanned recursively, and only the supported file types listed above are processed.
48
+
49
+ ## Parse Command Options
50
+
51
+ The `parse` command supports several flags to customize parsing behavior:
52
+
53
+ ### Flags
54
+
55
+ | Flag | Description |
56
+ |------|-------------|
57
+ | `--agentic` | Enables all agentic options for tables, text, and figures. Increases accuracy but also increases latency. Use when document quality or complex layouts require enhanced processing. |
58
+ | `--change-tracking` | Enables change tracking during parsing. Returns `<s>` tags around strikethrough text, `<u>` tags around underlined text, and `<change>` tags around colored adjacent strikethrough and underlined text. Useful for documents with revision history. |
59
+ | `--highlights` | Include highlighted text in the parsed output. |
60
+ | `--hyperlinks` | Include embedded hyperlinks in the parsed output. |
61
+ | `--comments` | Include document comments in the parsed output. |
62
+
63
+ ### Examples
64
+
65
+ ```bash
66
+ # Basic parse
67
+ reducto parse document.pdf
68
+
69
+ # Parse with maximum accuracy (slower)
70
+ reducto parse document.pdf --agentic
71
+
72
+ # Parse a contract with change tracking
73
+ reducto parse contract.pdf --change-tracking
74
+
75
+ # Parse with all metadata
76
+ reducto parse document.pdf --hyperlinks --comments --highlights
77
+
78
+ # Combine flags as needed
79
+ reducto parse legal_doc.pdf --agentic --change-tracking --comments
80
+ ```
81
+
82
+ ## Extract Command Overview
83
+
84
+ The `extract` command enables you to pull specific, structured data from your documents according to a schema you provide (using JSON Schema). It is designed to automate information extraction by mapping complex or unstructured documents—such as invoices, receipts, reports, forms, contracts, financial statements, or tables—into machine-readable JSON.
85
+
86
+ Common use cases include:
87
+ - Extracting line items, totals, vendor/customer info from invoices and receipts
88
+ - Pulling key fields, tables, or sections from contracts or legal documents
89
+ - Capturing form field values from scanned forms or applications
90
+ - Summarizing structured results from reports, statements, or medical records
91
+
92
+ By providing a schema, you ensure consistency and determinism, so the extracted JSON conforms exactly to your business requirements. This is especially valuable for automating downstream processing pipelines, integrating with databases, or feeding data to other tools.
93
+
94
+ You can perform extraction on individual files or batches (folders), and extracted payloads are saved as `<filename>.extract.json`.
95
+
96
+ ## Schema Guidelines for `reducto extract`
97
+
98
+ • Schemas must be valid JSON Schema documents.
99
+ • The top-level schema **must** be an object (`{"type": "object", ...}`) — inline strings or arrays are not permitted.
100
+ • Provide explicit property definitions so the extractor can map fields deterministically.
101
+ • Schemas may be supplied as file paths or inline JSON strings.
102
+
103
+ ### Example Schema
104
+
105
+ ```json
106
+ {
107
+ "type": "object",
108
+ "properties": {
109
+ "items": {
110
+ "type": "array",
111
+ "items": {
112
+ "type": "object",
113
+ "properties": {
114
+ "article_number": {"type": "string"},
115
+ "description": {"type": "string"},
116
+ "quantity": {"type": "number"},
117
+ "unit_price": {"type": "number"},
118
+ "total_price": {"type": "number"}
119
+ },
120
+ "required": [
121
+ "article_number",
122
+ "description",
123
+ "quantity",
124
+ "unit_price",
125
+ "total_price"
126
+ ]
127
+ }
128
+ }
129
+ },
130
+ "required": ["items"]
131
+ }
132
+ ```
133
+
134
+ You can reuse parses across multiple extractions: the CLI automatically detects existing `.parse.md` files, rehydrates the recorded job ID, and uses `jobid://<id>` references to accelerate extraction jobs.
135
+
136
+ ## Editing Documents with `reducto edit`
137
+
138
+ The `edit` command allows you to modify documents using natural language instructions. It uploads the document, applies the specified edits, and downloads the resulting file.
139
+
140
+ ### Usage
141
+
142
+ ```bash
143
+ reducto edit path/to/document.pdf --instructions "Your editing instructions here"
144
+ reducto edit path/to/document.pdf -i "Your editing instructions here"
145
+ ```
146
+
147
+ ### Parameters
148
+
149
+ | Parameter | Required | Description |
150
+ |-----------|----------|-------------|
151
+ | `path` | Yes | Path to a file or directory. Directories are scanned recursively for supported file types. |
152
+ | `--instructions`, `-i` | Yes | Natural language instructions describing the edits to apply. |
153
+
154
+ ### Output
155
+
156
+ Edited files are saved alongside the original with the naming pattern `<filename>.edited.<extension>`. For example:
157
+ - `invoice.pdf` → `invoice.edited.pdf`
158
+ - `report.docx` → `report.edited.docx`
159
+
160
+ ### Examples
161
+
162
+ ```bash
163
+ reducto edit contract.pdf -i "Fill in the client name as 'Acme Corporation' and set the contract date to January 15, 2024"
164
+
165
+ reducto edit document.pdf -i "Fill out the form with: Name: John Doe, Email: john@example.com, Select 'Yes' for newsletter subscription"
166
+ ```
167
+
168
+ ### Effective Instructions
169
+
170
+ For best results with the `--instructions` flag:
171
+ - Be specific about what content to modify and how
172
+ - Reference specific elements (headers, footers, tables, specific text)
173
+ - Describe the desired outcome clearly
174
+ - For bulk operations on directories, ensure instructions apply uniformly to all file types
175
+
@@ -0,0 +1,17 @@
1
+ reducto_cli/INSTRUCTIONS.md,sha256=GqtwwxmdHJrSLiZtv7FbLZgsH4zqTVf4eBFBAlRIrzI,6154
2
+ reducto_cli/__init__.py,sha256=_5mutx4EYCl4_jqZovpr8yIPn1BdTC-dwT9WfRvLRJM,141
3
+ reducto_cli/__main__.py,sha256=wu5N2wk8mvBgyvr2ghmQf4prezAe0_i-p123VVreyYc,62
4
+ reducto_cli/auth.py,sha256=cTVN7swoh9YacM1gt_0eNJqNUJvN8B-puECUpBNGKWg,6542
5
+ reducto_cli/cli.py,sha256=YX6FL_egqFNY3wiZkmKJiN4ZIOt0WyB_I2z39iP43CA,2706
6
+ reducto_cli/config.py,sha256=lZL5kz9cACLNfO6vdSohJegNyHGDqfW-ea5UuYK4JZA,1942
7
+ reducto_cli/editor.py,sha256=-Pf0eynMatWhii8OFzrcROFAHAMCk99LX5iEdMvTZq0,2010
8
+ reducto_cli/extractor.py,sha256=fO2zSJ9ecLsTk-VFkdTes0jFCAPOs_i6lVKCnmlrcrM,2903
9
+ reducto_cli/files.py,sha256=DAz9WUBV_YoyelLQS-k8Dowyr99tZVtOkadS4FB5I70,1406
10
+ reducto_cli/help_text.py,sha256=80uhM6NzgGdbuxgEv4Lx2XMe6b3I82l0hE-CgHqMWe0,655
11
+ reducto_cli/parser.py,sha256=it2LO7RznuVJ0TvVey9VcUIGjhfJVPkHl8-g__YqwrI,2878
12
+ reducto_cli/schema.py,sha256=Ttcx3IcyKVDrnJGmLxGeJhGH1QdIsvr-csnpwmDaxzA,601
13
+ reducto_cli-0.1.0.dist-info/METADATA,sha256=_ICs2adq_SnTvj8jW_b_GRs00HLzDk1_Auengwg-3Jc,6550
14
+ reducto_cli-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
15
+ reducto_cli-0.1.0.dist-info/entry_points.txt,sha256=dRLbGtD1LhU-eGfITuetnpvq6q-_ELrXJy4zzRnWz0w,48
16
+ reducto_cli-0.1.0.dist-info/top_level.txt,sha256=PkGOBDvo-HEUItp3EV_P0PMXKLudo6CJEtB9oofR_wE,12
17
+ reducto_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ reducto = reducto_cli.cli:app
@@ -0,0 +1 @@
1
+ reducto_cli