powder-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.
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: powder-cli
3
+ Version: 0.1.0
4
+ Summary: CLI for the Powder financial document processing API
5
+ Author-email: Powder <engineering@powderfi.com>
6
+ License: Proprietary
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: License :: Other/Proprietary License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: click>=8.0
17
+ Requires-Dist: requests>=2.28
18
+ Requires-Dist: rich>=13.0
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=7.0; extra == "dev"
21
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
22
+ Requires-Dist: responses>=0.23; extra == "dev"
23
+ Requires-Dist: ruff>=0.4.0; extra == "dev"
24
+
25
+ # powder-cli
26
+
27
+ Python CLI for the Powder API — upload financial statements and extract structured data.
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ pip install powder-cli
33
+ ```
34
+
35
+ Requires Python 3.10+.
36
+
37
+ ## Quick Start
38
+
39
+ ### 1. Set Your API Token
40
+
41
+ ```bash
42
+ export POWDER_API_TOKEN="your-token-here"
43
+ ```
44
+
45
+ Add to `~/.zshrc` or `~/.bashrc` to persist. To get a token, contact support@powderfi.com.
46
+
47
+ Alternatively, use `powder configure` for interactive setup (saves to `~/.config/powder/config.json`).
48
+
49
+ ### 2. Upload a Statement
50
+
51
+ ```bash
52
+ powder upload statement.pdf
53
+ ```
54
+
55
+ This returns an upload ID that you'll use to check status and retrieve data.
56
+
57
+ ### 3. Check Status
58
+
59
+ ```bash
60
+ powder status 12345
61
+ ```
62
+
63
+ Or watch until processing completes:
64
+
65
+ ```bash
66
+ powder status 12345 --watch
67
+ ```
68
+
69
+ ### 4. Retrieve Data
70
+
71
+ ```bash
72
+ powder data 12345
73
+ ```
74
+
75
+ View data as a table:
76
+
77
+ ```bash
78
+ powder data 12345 --format table
79
+ ```
80
+
81
+ ## Supported File Types
82
+
83
+ | Format | Extensions |
84
+ |--------|-----------|
85
+ | PDF | `.pdf` |
86
+ | Images | `.png`, `.jpg`, `.jpeg` |
87
+ | Spreadsheets | `.xlsx` |
88
+
89
+ Maximum file size: **50 MB**
90
+
91
+ ## JSON Output
92
+
93
+ All CLI commands support `--json` for machine-readable output. The flag goes **before** the command:
94
+
95
+ ```bash
96
+ powder --json upload statement.pdf
97
+ powder --json status 12345
98
+ powder --json data 12345
99
+ ```
100
+
101
+ ## Authentication
102
+
103
+ The CLI checks for credentials in this order:
104
+
105
+ 1. `POWDER_API_TOKEN` environment variable
106
+ 2. `~/.config/powder/config.json` (created by `powder configure`)
107
+
108
+ To get an API token, contact support@powderfi.com.
109
+
110
+ ## Claude Code Plugin
111
+
112
+ A Claude Code plugin is available at [Powder-Finance/claude-code-powder-plugin](https://github.com/Powder-Finance/claude-code-powder-plugin) for conversational document uploads:
113
+
114
+ ```
115
+ /Powder:setup # Install CLI + verify token
116
+ /Powder:upload # Upload → process → extract → summarize
117
+ /Powder:data # Fetch data for an existing upload
118
+ /Powder:status # Check processing status
119
+ ```
@@ -0,0 +1,95 @@
1
+ # powder-cli
2
+
3
+ Python CLI for the Powder API — upload financial statements and extract structured data.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install powder-cli
9
+ ```
10
+
11
+ Requires Python 3.10+.
12
+
13
+ ## Quick Start
14
+
15
+ ### 1. Set Your API Token
16
+
17
+ ```bash
18
+ export POWDER_API_TOKEN="your-token-here"
19
+ ```
20
+
21
+ Add to `~/.zshrc` or `~/.bashrc` to persist. To get a token, contact support@powderfi.com.
22
+
23
+ Alternatively, use `powder configure` for interactive setup (saves to `~/.config/powder/config.json`).
24
+
25
+ ### 2. Upload a Statement
26
+
27
+ ```bash
28
+ powder upload statement.pdf
29
+ ```
30
+
31
+ This returns an upload ID that you'll use to check status and retrieve data.
32
+
33
+ ### 3. Check Status
34
+
35
+ ```bash
36
+ powder status 12345
37
+ ```
38
+
39
+ Or watch until processing completes:
40
+
41
+ ```bash
42
+ powder status 12345 --watch
43
+ ```
44
+
45
+ ### 4. Retrieve Data
46
+
47
+ ```bash
48
+ powder data 12345
49
+ ```
50
+
51
+ View data as a table:
52
+
53
+ ```bash
54
+ powder data 12345 --format table
55
+ ```
56
+
57
+ ## Supported File Types
58
+
59
+ | Format | Extensions |
60
+ |--------|-----------|
61
+ | PDF | `.pdf` |
62
+ | Images | `.png`, `.jpg`, `.jpeg` |
63
+ | Spreadsheets | `.xlsx` |
64
+
65
+ Maximum file size: **50 MB**
66
+
67
+ ## JSON Output
68
+
69
+ All CLI commands support `--json` for machine-readable output. The flag goes **before** the command:
70
+
71
+ ```bash
72
+ powder --json upload statement.pdf
73
+ powder --json status 12345
74
+ powder --json data 12345
75
+ ```
76
+
77
+ ## Authentication
78
+
79
+ The CLI checks for credentials in this order:
80
+
81
+ 1. `POWDER_API_TOKEN` environment variable
82
+ 2. `~/.config/powder/config.json` (created by `powder configure`)
83
+
84
+ To get an API token, contact support@powderfi.com.
85
+
86
+ ## Claude Code Plugin
87
+
88
+ A Claude Code plugin is available at [Powder-Finance/claude-code-powder-plugin](https://github.com/Powder-Finance/claude-code-powder-plugin) for conversational document uploads:
89
+
90
+ ```
91
+ /Powder:setup # Install CLI + verify token
92
+ /Powder:upload # Upload → process → extract → summarize
93
+ /Powder:data # Fetch data for an existing upload
94
+ /Powder:status # Check processing status
95
+ ```
@@ -0,0 +1,70 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "powder-cli"
7
+ dynamic = ["version"]
8
+ description = "CLI for the Powder financial document processing API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "Proprietary"}
12
+ authors = [{name = "Powder", email = "engineering@powderfi.com"}]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Intended Audience :: Developers",
16
+ "License :: Other/Proprietary License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ ]
22
+ dependencies = [
23
+ "click>=8.0",
24
+ "requests>=2.28",
25
+ "rich>=13.0",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ dev = [
30
+ "pytest>=7.0",
31
+ "pytest-cov>=4.0",
32
+ "responses>=0.23",
33
+ "ruff>=0.4.0",
34
+ ]
35
+
36
+ [project.scripts]
37
+ powder = "powder.cli:cli"
38
+
39
+ [tool.setuptools.packages.find]
40
+ where = ["src"]
41
+
42
+ [tool.setuptools.dynamic]
43
+ version = {attr = "powder.__version__"}
44
+
45
+ [tool.ruff]
46
+ target-version = "py310"
47
+ line-length = 120
48
+
49
+ [tool.ruff.lint]
50
+ select = [
51
+ "E", # pycodestyle errors
52
+ "W", # pycodestyle warnings
53
+ "F", # pyflakes
54
+ "I", # isort
55
+ "N", # pep8-naming
56
+ "UP", # pyupgrade
57
+ "B", # flake8-bugbear
58
+ "SIM", # flake8-simplify
59
+ "RUF", # ruff-specific rules
60
+ ]
61
+ ignore = [
62
+ "E501", # line too long (handled by formatter)
63
+ ]
64
+
65
+ [tool.ruff.lint.isort]
66
+ known-first-party = ["powder"]
67
+
68
+ [tool.pytest.ini_options]
69
+ testpaths = ["tests"]
70
+ addopts = "--cov=powder --cov-report=term-missing --cov-fail-under=85"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ from powder.client import PowderClient
2
+ from powder.errors import PowderAPIError, PowderAuthError, PowderTimeoutError
3
+
4
+ __version__ = "0.1.0"
5
+ __all__ = ["PowderAPIError", "PowderAuthError", "PowderClient", "PowderTimeoutError"]
@@ -0,0 +1,213 @@
1
+ import json as json_lib
2
+ from functools import wraps
3
+ from pathlib import Path
4
+
5
+ import click
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from powder import __version__
10
+ from powder.client import PowderClient
11
+ from powder.config import clear_token, save_token
12
+ from powder.errors import PowderAPIError, PowderAuthError, PowderTimeoutError
13
+
14
+
15
+ def handle_errors(f):
16
+ @wraps(f)
17
+ def wrapper(*args, **kwargs):
18
+ ctx = click.get_current_context()
19
+ json_output = ctx.find_root().params.get("json_output", False)
20
+ try:
21
+ return f(*args, **kwargs)
22
+ except PowderAuthError as e:
23
+ if json_output:
24
+ click.echo(json_lib.dumps({"error": str(e), "status_code": e.status_code}))
25
+ else:
26
+ click.echo(f"Error: {e}", err=True)
27
+ if "configure" not in str(e).lower():
28
+ click.echo("Run 'powder configure' to set up your API key.", err=True)
29
+ ctx.exit(1)
30
+ except PowderTimeoutError as e:
31
+ if json_output:
32
+ click.echo(json_lib.dumps({"error": str(e), "upload_id": e.upload_id}))
33
+ else:
34
+ click.echo(f"Error: {e}", err=True)
35
+ ctx.exit(1)
36
+ except PowderAPIError as e:
37
+ if json_output:
38
+ click.echo(json_lib.dumps({"error": str(e), "status_code": e.status_code}))
39
+ else:
40
+ click.echo(f"Error: {e}", err=True)
41
+ ctx.exit(1)
42
+ except FileNotFoundError as e:
43
+ if json_output:
44
+ click.echo(json_lib.dumps({"error": str(e)}))
45
+ else:
46
+ click.echo(f"Error: {e}", err=True)
47
+ ctx.exit(1)
48
+ except (ValueError, PermissionError) as e:
49
+ if json_output:
50
+ click.echo(json_lib.dumps({"error": str(e)}))
51
+ else:
52
+ click.echo(f"Error: {e}", err=True)
53
+ ctx.exit(1)
54
+ except KeyboardInterrupt:
55
+ click.echo("\nAborted.", err=True)
56
+ ctx.exit(130)
57
+ except Exception as e:
58
+ if json_output:
59
+ click.echo(json_lib.dumps({"error": f"Unexpected error: {e}"}))
60
+ else:
61
+ click.echo(f"Unexpected error: {e}", err=True)
62
+ ctx.exit(1)
63
+
64
+ return wrapper
65
+
66
+
67
+ @click.group(invoke_without_command=True)
68
+ @click.option("--json", "json_output", is_flag=True, help="Output JSON for all commands")
69
+ @click.option("--version", is_flag=True, help="Show version and exit")
70
+ @click.pass_context
71
+ def cli(ctx, json_output, version):
72
+ ctx.ensure_object(dict)
73
+ ctx.obj["json_output"] = json_output
74
+ if version:
75
+ click.echo(f"powder {__version__}")
76
+ ctx.exit(0)
77
+
78
+
79
+ @cli.command(short_help="Set up API credentials")
80
+ @click.option("--clear", is_flag=True, help="Remove stored credentials")
81
+ @click.pass_context
82
+ @handle_errors
83
+ def configure(ctx, clear):
84
+ if clear:
85
+ clear_token()
86
+ click.echo("✓ Credentials removed.")
87
+ return
88
+
89
+ token = click.prompt("Enter your Powder API key", hide_input=True)
90
+
91
+ # Validate before saving — don't persist invalid tokens
92
+ client = PowderClient(token=token)
93
+ try:
94
+ client._request("GET", "file_uploads/")
95
+ except PowderAuthError:
96
+ click.echo("✗ API key rejected by Powder API", err=True)
97
+ ctx.exit(1)
98
+ except PowderAPIError:
99
+ pass # Non-auth error means auth itself succeeded
100
+
101
+ save_token(token)
102
+ click.echo("✓ API key saved to ~/.config/powder/config.json")
103
+
104
+
105
+ @cli.command(short_help="Upload a financial statement")
106
+ @click.argument("path")
107
+ @click.option(
108
+ "--type",
109
+ "statement_type",
110
+ default="brokerage",
111
+ help="Statement type (default: brokerage)",
112
+ )
113
+ @click.option("--portfolio", "portfolio_id", type=int, default=None, help="Portfolio ID")
114
+ @click.pass_context
115
+ @handle_errors
116
+ def upload(ctx, path, statement_type, portfolio_id):
117
+ p = Path(path)
118
+
119
+ client = PowderClient()
120
+ result = client.upload(path, statement_type, portfolio_id)
121
+
122
+ if ctx.obj.get("json_output"):
123
+ click.echo(json_lib.dumps(result, indent=2))
124
+ else:
125
+ click.echo(f"✓ Uploaded {p.name}")
126
+ click.echo(f" ID: {result.get('id', 'N/A')}")
127
+ click.echo(f" Status: {result.get('status', 'N/A')}")
128
+ click.echo(f" Portfolio: {result.get('portfolio_id', 'N/A')}")
129
+
130
+
131
+ @cli.command(short_help="Check processing status")
132
+ @click.argument("upload_id")
133
+ @click.option("--watch/--no-watch", default=False, help="Poll until processing completes")
134
+ @click.option("--timeout", default=600, type=int, help="Watch timeout in seconds (default 600)")
135
+ @click.pass_context
136
+ @handle_errors
137
+ def status(ctx, upload_id, watch, timeout):
138
+ client = PowderClient()
139
+
140
+ if watch:
141
+ result = client.watch(upload_id, timeout=timeout, callback=lambda r: click.echo(".", nl=False))
142
+ click.echo()
143
+ else:
144
+ result = client.status(upload_id)
145
+
146
+ if ctx.obj.get("json_output"):
147
+ click.echo(json_lib.dumps(result, indent=2))
148
+ else:
149
+ status_val = result.get("status", "unknown")
150
+ click.echo(f"Status: {status_val}")
151
+ if result.get("portfolio_id"):
152
+ click.echo(f" Portfolio: {result['portfolio_id']}")
153
+ if status_val == "closed" and "error" in result:
154
+ err = result["error"]
155
+ if isinstance(err, dict):
156
+ click.echo(f" Error: {err.get('code', 'unknown')} — {err.get('message', '')}")
157
+ else:
158
+ click.echo(f" Error: {err}")
159
+ if "closed_at" in result:
160
+ click.echo(f" Closed at: {result['closed_at']}")
161
+
162
+
163
+ @cli.command(short_help="Retrieve extracted data")
164
+ @click.argument("upload_id")
165
+ @click.option(
166
+ "--format",
167
+ "fmt",
168
+ type=click.Choice(["json", "table"]),
169
+ default="json",
170
+ help="Output format",
171
+ )
172
+ @click.option("--page", default=1, type=int, help="Page number (default: 1)")
173
+ @click.pass_context
174
+ @handle_errors
175
+ def data(ctx, upload_id, fmt, page):
176
+ client = PowderClient()
177
+ result = client.data(upload_id, page=page)
178
+
179
+ if fmt == "json" or ctx.obj.get("json_output"):
180
+ click.echo(json_lib.dumps(result, indent=2))
181
+ return
182
+
183
+ data_dict = result.get("data", {})
184
+ rows = None
185
+ title = None
186
+
187
+ if isinstance(data_dict, dict):
188
+ ownerships = data_dict.get("ownerships")
189
+ transactions = data_dict.get("transactions")
190
+ if isinstance(ownerships, list):
191
+ rows = ownerships
192
+ title = "Ownerships"
193
+ elif isinstance(transactions, list):
194
+ rows = transactions
195
+ title = "Transactions"
196
+
197
+ if rows and isinstance(rows, list) and isinstance(rows[0], dict):
198
+ table = Table(title=title)
199
+ columns = list(rows[0].keys())
200
+ for col in columns:
201
+ table.add_column(str(col))
202
+ for item in rows:
203
+ table.add_row(*[str(item.get(col, "")) for col in columns])
204
+ Console().print(table)
205
+ else:
206
+ click.echo("Data structure too complex for table format, showing JSON.")
207
+ click.echo(json_lib.dumps(result, indent=2))
208
+ return
209
+
210
+ if result.get("count", 0) > 100:
211
+ total = result["count"]
212
+ total_pages = (total + 99) // 100
213
+ click.echo(f"\nShowing page {page} of {total_pages} ({total} total items). Use --page {page + 1} to see more.")
@@ -0,0 +1,147 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ from collections.abc import Callable
6
+ from pathlib import Path
7
+
8
+ import requests
9
+
10
+ from powder.config import get_token
11
+ from powder.errors import PowderAPIError, PowderAuthError, PowderTimeoutError
12
+
13
+ BASE_URL = "https://api.powderfi.com/api/v1.0/public/"
14
+ SUPPORTED_EXTENSIONS = {".pdf", ".png", ".jpg", ".jpeg", ".xlsx"}
15
+ TERMINAL_STATUSES = {"done", "failed", "error", "deleted", "closed"}
16
+ MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
17
+
18
+ VALIDATION_ERROR_MESSAGES = {
19
+ "PASSWORD_PROTECTED_PDF": "PDF is password-protected. Remove the password and try again.",
20
+ "CORRUPT_PDF": "PDF file is corrupt or damaged.",
21
+ "TRUNCATED_PDF": "PDF file appears truncated or incomplete.",
22
+ "UNREADABLE_PDF": "PDF file could not be read.",
23
+ "EMPTY_PDF": "PDF file has no pages.",
24
+ "INVALID_PDF_HEADER": "File does not appear to be a valid PDF.",
25
+ "PDF_FILE_READ_ERROR": "Error reading PDF file.",
26
+ "CORRUPT_EXCEL_ZIP": "Excel file is corrupt.",
27
+ "INVALID_EXCEL_FILE": "File is not a valid Excel file.",
28
+ "INVALID_EXCEL_FORMAT": "Unsupported Excel format.",
29
+ "EMPTY_EXCEL": "Excel file has no data.",
30
+ "EXCEL_FILE_TOO_LARGE": "Excel file is too large to process.",
31
+ "EXCEL_FILE_READ_ERROR": "Error reading Excel file.",
32
+ "PASSWORD_PROTECTED_EXCEL": "Excel file is password-protected. Remove the password and try again.",
33
+ "UNSUPPORTED_FILE_TYPE": "File type is not supported.",
34
+ }
35
+
36
+
37
+ class PowderClient:
38
+ def __init__(self, token: str | None = None, base_url: str | None = None):
39
+ """Initialize the Powder API client with auth token and base URL."""
40
+ self.token = token if token is not None else get_token()
41
+ self.base_url = base_url if base_url is not None else BASE_URL
42
+ if not self.base_url.endswith("/"):
43
+ self.base_url += "/"
44
+
45
+ self.session = requests.Session()
46
+ self.session.headers.update({"Authorization": f"Bearer {self.token}", "Accept": "application/json"})
47
+
48
+ def _request(self, method: str, path: str, **kwargs) -> dict:
49
+ """Make an authenticated request to the Powder API and handle errors."""
50
+ url = self.base_url + path.lstrip("/")
51
+ # Use longer timeout for uploads (POST with files)
52
+ default_timeout = 120 if "files" in kwargs else 30
53
+ kwargs.setdefault("timeout", default_timeout)
54
+ try:
55
+ response = self.session.request(method, url, **kwargs)
56
+ except requests.exceptions.SSLError:
57
+ raise PowderAPIError(message="SSL certificate error connecting to Powder API") from None
58
+ except requests.ConnectionError:
59
+ raise PowderAPIError(message="Cannot reach Powder API. Check your internet connection.") from None
60
+ except requests.Timeout:
61
+ raise PowderAPIError(message="Powder API request timed out (30s). Check your connection.") from None
62
+
63
+ try:
64
+ body = response.json()
65
+ if not isinstance(body, dict):
66
+ body = {"raw": body}
67
+ except (json.JSONDecodeError, ValueError):
68
+ if isinstance(response.text, str):
69
+ body = {"raw": response.text[:500]}
70
+ else:
71
+ body = {"raw": str(response.content[:500])}
72
+
73
+ status_code = response.status_code
74
+
75
+ if 200 <= status_code <= 299:
76
+ return body
77
+ elif status_code == 401:
78
+ raise PowderAuthError(message=body.get("message", "Authentication failed"), status_code=401, detail=body)
79
+ elif status_code == 422:
80
+ error_code = body.get("error_code")
81
+ human_msg = VALIDATION_ERROR_MESSAGES.get(error_code, body.get("message", "Validation error"))
82
+ raise PowderAPIError(message=human_msg, status_code=422, detail=body)
83
+ elif status_code == 400:
84
+ raise PowderAPIError(message=body.get("message", "Bad request"), status_code=400, detail=body)
85
+ elif status_code == 403:
86
+ raise PowderAPIError(message="Access denied", status_code=403, detail=body)
87
+ elif status_code == 429:
88
+ retry_after = response.headers.get("Retry-After")
89
+ msg = f"Rate limited. Retry after {retry_after}s" if retry_after else "Rate limited by Powder API"
90
+ raise PowderAPIError(message=msg, status_code=429, detail=body)
91
+ elif status_code >= 500:
92
+ raise PowderAPIError(message="Powder API server error", status_code=status_code, detail=body)
93
+
94
+ raise PowderAPIError(message=f"Unexpected error: {status_code}", status_code=status_code, detail=body)
95
+
96
+ def upload(self, file_path: str, statement_type: str = "brokerage", portfolio_id: int | None = None) -> dict:
97
+ """Upload a financial statement file and return the upload record."""
98
+ resolved = Path(file_path).resolve()
99
+
100
+ if not resolved.exists():
101
+ raise FileNotFoundError(f"File not found: {file_path}")
102
+
103
+ if resolved.suffix.lower() not in SUPPORTED_EXTENSIONS:
104
+ raise ValueError(
105
+ f"Unsupported file type '{resolved.suffix}'. Supported: {', '.join(sorted(SUPPORTED_EXTENSIONS))}"
106
+ )
107
+
108
+ size = resolved.stat().st_size
109
+ if size == 0:
110
+ raise ValueError("File is empty")
111
+
112
+ if size > MAX_FILE_SIZE:
113
+ raise ValueError(f"File too large ({size / 1024 / 1024:.1f}MB). Maximum: 50MB")
114
+
115
+ try:
116
+ with open(resolved, "rb") as f:
117
+ files = {"file": (resolved.name, f)}
118
+ data = {"statement_type": statement_type}
119
+ if portfolio_id is not None:
120
+ data["portfolio_id"] = portfolio_id
121
+ return self._request("POST", "file_uploads/", files=files, data=data)
122
+ except PermissionError:
123
+ raise PermissionError(f"Cannot read file (permission denied): {file_path}") from None
124
+
125
+ def status(self, upload_id: str) -> dict:
126
+ """Get the current status of an upload."""
127
+ return self._request("GET", f"file_uploads/{upload_id}/")
128
+
129
+ def watch(
130
+ self, upload_id: str, timeout: int = 600, interval: int = 3, callback: Callable[[dict], None] | None = None
131
+ ) -> dict:
132
+ """Poll upload status until completion or timeout, with optional progress callback."""
133
+ start = time.monotonic()
134
+ while True:
135
+ result = self.status(upload_id)
136
+ if callback:
137
+ callback(result)
138
+ if result.get("status") in TERMINAL_STATUSES:
139
+ return result
140
+ elapsed = time.monotonic() - start
141
+ if elapsed >= timeout:
142
+ raise PowderTimeoutError(upload_id=upload_id, elapsed_seconds=elapsed)
143
+ time.sleep(interval)
144
+
145
+ def data(self, upload_id: str, page: int = 1) -> dict:
146
+ """Retrieve extracted data from a processed upload."""
147
+ return self._request("GET", f"file_uploads/{upload_id}/data/", params={"page": page})