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.
- powder_cli-0.1.0/PKG-INFO +119 -0
- powder_cli-0.1.0/README.md +95 -0
- powder_cli-0.1.0/pyproject.toml +70 -0
- powder_cli-0.1.0/setup.cfg +4 -0
- powder_cli-0.1.0/src/powder/__init__.py +5 -0
- powder_cli-0.1.0/src/powder/cli.py +213 -0
- powder_cli-0.1.0/src/powder/client.py +147 -0
- powder_cli-0.1.0/src/powder/config.py +74 -0
- powder_cli-0.1.0/src/powder/errors.py +35 -0
- powder_cli-0.1.0/src/powder_cli.egg-info/PKG-INFO +119 -0
- powder_cli-0.1.0/src/powder_cli.egg-info/SOURCES.txt +16 -0
- powder_cli-0.1.0/src/powder_cli.egg-info/dependency_links.txt +1 -0
- powder_cli-0.1.0/src/powder_cli.egg-info/entry_points.txt +2 -0
- powder_cli-0.1.0/src/powder_cli.egg-info/requires.txt +9 -0
- powder_cli-0.1.0/src/powder_cli.egg-info/top_level.txt +1 -0
- powder_cli-0.1.0/tests/test_cli.py +241 -0
- powder_cli-0.1.0/tests/test_client.py +507 -0
- powder_cli-0.1.0/tests/test_config.py +84 -0
|
@@ -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,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})
|