paper-stuff 0.2.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.
Files changed (36) hide show
  1. paper_stuff-0.2.0/.gitignore +67 -0
  2. paper_stuff-0.2.0/PKG-INFO +13 -0
  3. paper_stuff-0.2.0/pyproject.toml +24 -0
  4. paper_stuff-0.2.0/src/paper_stuff_cli/__init__.py +3 -0
  5. paper_stuff-0.2.0/src/paper_stuff_cli/client.py +145 -0
  6. paper_stuff-0.2.0/src/paper_stuff_cli/commands/__init__.py +0 -0
  7. paper_stuff-0.2.0/src/paper_stuff_cli/commands/api_key.py +93 -0
  8. paper_stuff-0.2.0/src/paper_stuff_cli/commands/auth.py +82 -0
  9. paper_stuff-0.2.0/src/paper_stuff_cli/commands/batch.py +76 -0
  10. paper_stuff-0.2.0/src/paper_stuff_cli/commands/dashboard.py +28 -0
  11. paper_stuff-0.2.0/src/paper_stuff_cli/commands/document.py +412 -0
  12. paper_stuff-0.2.0/src/paper_stuff_cli/commands/filing.py +78 -0
  13. paper_stuff-0.2.0/src/paper_stuff_cli/commands/group.py +113 -0
  14. paper_stuff-0.2.0/src/paper_stuff_cli/commands/health.py +18 -0
  15. paper_stuff-0.2.0/src/paper_stuff_cli/commands/mcp.py +32 -0
  16. paper_stuff-0.2.0/src/paper_stuff_cli/commands/settings.py +53 -0
  17. paper_stuff-0.2.0/src/paper_stuff_cli/commands/tax.py +72 -0
  18. paper_stuff-0.2.0/src/paper_stuff_cli/config.py +148 -0
  19. paper_stuff-0.2.0/src/paper_stuff_cli/decorators.py +49 -0
  20. paper_stuff-0.2.0/src/paper_stuff_cli/errors.py +74 -0
  21. paper_stuff-0.2.0/src/paper_stuff_cli/main.py +145 -0
  22. paper_stuff-0.2.0/src/paper_stuff_cli/mcp_server.py +316 -0
  23. paper_stuff-0.2.0/src/paper_stuff_cli/output.py +139 -0
  24. paper_stuff-0.2.0/tests/__init__.py +0 -0
  25. paper_stuff-0.2.0/tests/conftest.py +17 -0
  26. paper_stuff-0.2.0/tests/test_client.py +76 -0
  27. paper_stuff-0.2.0/tests/test_commands/__init__.py +0 -0
  28. paper_stuff-0.2.0/tests/test_commands/test_auth.py +103 -0
  29. paper_stuff-0.2.0/tests/test_commands/test_batch.py +69 -0
  30. paper_stuff-0.2.0/tests/test_commands/test_document.py +183 -0
  31. paper_stuff-0.2.0/tests/test_config.py +98 -0
  32. paper_stuff-0.2.0/tests/test_mcp_protocol.py +68 -0
  33. paper_stuff-0.2.0/tests/test_mcp_stdio_smoke.py +35 -0
  34. paper_stuff-0.2.0/tests/test_mcp_tools.py +276 -0
  35. paper_stuff-0.2.0/tests/test_output.py +43 -0
  36. paper_stuff-0.2.0/uv.lock +1050 -0
@@ -0,0 +1,67 @@
1
+ # Python
2
+ *.pyc
3
+ *.pyo
4
+ *.pyd
5
+ *.pyw
6
+ *.pyz
7
+ *.pywz
8
+ *.pyzwz
9
+ *.pyzwzw
10
+ *.pyzwzwz
11
+ *.pyzwzwzw
12
+ *.pyzwzwzwz
13
+ __pycache__/
14
+ .venv/
15
+
16
+ # Environment files
17
+ .env
18
+ .env.local
19
+ env/*.env
20
+ !env/*.env.example
21
+
22
+ # Node.js
23
+ node_modules/
24
+ .next/
25
+ *.log
26
+
27
+ # HTTPS certificates
28
+ certs/
29
+ *.pem
30
+ *.key
31
+ *.crt
32
+
33
+ # Secrets (service account keys, etc.)
34
+ secrets/
35
+
36
+ # Logs directory (contains sensitive Azure response data)
37
+ logs/
38
+ *.log
39
+
40
+ # Terraform
41
+ *.tfstate
42
+ *.tfstate.*
43
+ .terraform/
44
+ terraform.tfvars
45
+ secrets.auto.tfvars
46
+ !*.tfvars.example
47
+
48
+ # Test databases
49
+ test.db
50
+
51
+ # Database backups (never commit dumps — they contain all user PII)
52
+ backups/
53
+ *.dump
54
+ *.sql.gz
55
+
56
+ # Fixtures (may contain PII)
57
+ fixtures/
58
+
59
+ # Prompts (may contain secrets)
60
+ prompts/
61
+
62
+ # IDE
63
+ .idea/
64
+ .vscode/
65
+
66
+ # Local dev FileStore
67
+ .local-storage/
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.5
2
+ Name: paper-stuff
3
+ Version: 0.2.0
4
+ Summary: CLI and MCP server for Paper Stuff document management
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: httpx<1.0,>=0.28
7
+ Requires-Dist: mcp<2,>=1.8
8
+ Requires-Dist: pydantic<3.0,>=2.0
9
+ Requires-Dist: typer[all]<1.0,>=0.15
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
12
+ Requires-Dist: pytest>=8.0; extra == 'dev'
13
+ Requires-Dist: respx>=0.22; extra == 'dev'
@@ -0,0 +1,24 @@
1
+ [project]
2
+ name = "paper-stuff"
3
+ version = "0.2.0"
4
+ description = "CLI and MCP server for Paper Stuff document management"
5
+ requires-python = ">=3.11"
6
+ dependencies = [
7
+ "typer[all]>=0.15,<1.0",
8
+ "httpx>=0.28,<1.0",
9
+ "pydantic>=2.0,<3.0",
10
+ "mcp>=1.8,<2", # 1.8 = first release with streamable HTTP (--transport http); tested on 1.30
11
+ ]
12
+
13
+ [project.optional-dependencies]
14
+ dev = ["pytest>=8.0", "respx>=0.22", "pytest-cov>=5.0"]
15
+
16
+ [project.scripts]
17
+ paper-stuff = "paper_stuff_cli.main:app"
18
+
19
+ [build-system]
20
+ requires = ["hatchling"]
21
+ build-backend = "hatchling.build"
22
+
23
+ [tool.hatch.build.targets.wheel]
24
+ packages = ["src/paper_stuff_cli"]
@@ -0,0 +1,3 @@
1
+ """Paper Stuff CLI — programmatic interface for Paper Stuff document management."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,145 @@
1
+ """HTTP client: auth injection, error mapping, verbose logging."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from paper_stuff_cli.config import redact_key, resolve_config
11
+ from paper_stuff_cli.errors import CLIError, cli_error_from_exception, cli_error_from_status
12
+
13
+
14
+ class APIClient:
15
+ """Paper Stuff API client with auth injection and error handling."""
16
+
17
+ def __init__(
18
+ self,
19
+ *,
20
+ api_url: str | None = None,
21
+ api_key: str | None = None,
22
+ token: str | None = None,
23
+ profile: str | None = None,
24
+ verbose: bool = False,
25
+ timeout: float = 30.0,
26
+ ):
27
+ resolved = resolve_config(
28
+ cli_api_url=api_url,
29
+ cli_api_key=api_key,
30
+ cli_token=token,
31
+ cli_profile=profile,
32
+ )
33
+ self.api_url = resolved["api_url"].rstrip("/")
34
+ self._api_key = resolved["api_key"]
35
+ self._token = resolved["token"]
36
+ self._profile = profile
37
+ self._verbose = verbose
38
+ self._client = httpx.Client(
39
+ base_url=self.api_url,
40
+ timeout=timeout,
41
+ follow_redirects=True,
42
+ )
43
+
44
+ def reload_credentials(self) -> None:
45
+ """Re-resolve key/token from the config chain (env, dir config, profile) — used after a 401."""
46
+ resolved = resolve_config(cli_profile=self._profile)
47
+ self._api_key, self._token = resolved["api_key"], resolved["token"]
48
+
49
+ def _auth_headers(self) -> dict[str, str]:
50
+ """Build auth headers. Priority: API key > JWT token."""
51
+ if self._api_key:
52
+ return {"X-API-Key": self._api_key}
53
+ if self._token:
54
+ return {"Authorization": f"Bearer {self._token}"}
55
+ return {}
56
+
57
+ def _log_request(self, method: str, path: str) -> None:
58
+ if self._verbose:
59
+ auth_info = ""
60
+ if self._api_key:
61
+ auth_info = f" [key={redact_key(self._api_key)}]"
62
+ elif self._token:
63
+ auth_info = " [jwt=****]"
64
+ print(f" {method} {self.api_url}{path}{auth_info}", file=sys.stderr)
65
+
66
+ def _log_response(self, resp: httpx.Response) -> None:
67
+ if self._verbose:
68
+ print(f" → {resp.status_code} ({len(resp.content)} bytes)", file=sys.stderr)
69
+
70
+ def _raise_for_status(self, resp: httpx.Response) -> None:
71
+ """Raise a CLIError from the response body if the status is non-2xx."""
72
+ if resp.status_code >= 400:
73
+ detail = ""
74
+ try:
75
+ body = resp.json()
76
+ detail = body.get("detail", "") if isinstance(body, dict) else str(body)
77
+ except (ValueError, KeyError):
78
+ detail = resp.text[:200]
79
+ raise cli_error_from_status(resp.status_code, detail, resp.headers.get("Retry-After"))
80
+
81
+ def _handle_response(self, resp: httpx.Response) -> Any:
82
+ """Parse JSON response; raise CLIError on non-2xx."""
83
+ self._log_response(resp)
84
+ self._raise_for_status(resp)
85
+ if resp.status_code == 204 or not resp.content:
86
+ return {}
87
+ return resp.json()
88
+
89
+ def request(
90
+ self,
91
+ method: str,
92
+ path: str,
93
+ *,
94
+ json: Any = None,
95
+ params: dict[str, Any] | None = None,
96
+ data: Any = None,
97
+ files: Any = None,
98
+ headers: dict[str, str] | None = None,
99
+ timeout: float | None = None,
100
+ ) -> Any:
101
+ """Make an authenticated API request. ``timeout`` overrides the client default for this call only."""
102
+ merged_headers = {**self._auth_headers(), **(headers or {})}
103
+ self._log_request(method, path)
104
+ extra = {"timeout": timeout} if timeout is not None else {}
105
+ try:
106
+ resp = self._client.request(
107
+ method,
108
+ path,
109
+ json=json,
110
+ params=params,
111
+ content=data,
112
+ files=files,
113
+ headers=merged_headers,
114
+ **extra,
115
+ )
116
+ except (httpx.ConnectError, httpx.TimeoutException) as exc:
117
+ raise cli_error_from_exception(exc)
118
+ return self._handle_response(resp)
119
+
120
+ def get(self, path: str, **kwargs: Any) -> Any:
121
+ return self.request("GET", path, **kwargs)
122
+
123
+ def post(self, path: str, **kwargs: Any) -> Any:
124
+ return self.request("POST", path, **kwargs)
125
+
126
+ def patch(self, path: str, **kwargs: Any) -> Any:
127
+ return self.request("PATCH", path, **kwargs)
128
+
129
+ def delete(self, path: str, **kwargs: Any) -> Any:
130
+ return self.request("DELETE", path, **kwargs)
131
+
132
+ def download(self, path: str) -> httpx.Response:
133
+ """Download a file (returns raw response for streaming)."""
134
+ merged_headers = self._auth_headers()
135
+ self._log_request("GET", path)
136
+ try:
137
+ resp = self._client.get(path, headers=merged_headers)
138
+ except (httpx.ConnectError, httpx.TimeoutException) as exc:
139
+ raise cli_error_from_exception(exc)
140
+ self._log_response(resp)
141
+ self._raise_for_status(resp)
142
+ return resp
143
+
144
+ def close(self) -> None:
145
+ self._client.close()
@@ -0,0 +1,93 @@
1
+ """api-key — create, list, rename, revoke."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ from paper_stuff_cli.decorators import handle_cli_errors
10
+ from paper_stuff_cli.output import print_error, print_success
11
+
12
+ api_key_app = typer.Typer(help="API key management.")
13
+
14
+ _KEY_COLUMNS = [
15
+ ("ID", "id"),
16
+ ("Name", "name"),
17
+ ("Prefix", "prefix"),
18
+ ("Scopes", "scopes"),
19
+ ("Created", "created_at"),
20
+ ("Last Used", "last_used_at"),
21
+ ("Expires", "expires_at"),
22
+ ]
23
+
24
+ _VALID_SCOPES = {"upload", "read", "read_detail", "write", "delete", "export", "admin"}
25
+
26
+
27
+ @api_key_app.command()
28
+ @handle_cli_errors
29
+ def create(
30
+ ctx: typer.Context,
31
+ name: str = typer.Argument(help="Key name"),
32
+ group_id: Optional[str] = typer.Option(None, "--group-id", help="Group ID"),
33
+ expires_in_days: Optional[int] = typer.Option(None, "--expires-in-days", help="Expiry in days"),
34
+ scopes: Optional[list[str]] = typer.Option(None, "--scope", help="Scopes (repeatable). Valid: upload, read, read_detail, write, delete, export, admin"),
35
+ ) -> None:
36
+ """Create a new API key."""
37
+ client = ctx.obj["client"]
38
+ fmt = ctx.obj["format"]
39
+ body: dict = {"name": name}
40
+ if group_id:
41
+ body["group_id"] = group_id
42
+ if expires_in_days:
43
+ body["expires_in_days"] = expires_in_days
44
+ if scopes:
45
+ invalid = set(scopes) - _VALID_SCOPES
46
+ if invalid:
47
+ print_error("VALIDATION_ERROR", f"Invalid scopes: {', '.join(sorted(invalid))}", 400, fmt=fmt)
48
+ raise typer.Exit(1)
49
+ body["scopes"] = list(set(scopes))
50
+ data = client.post("/api-keys", json=body)
51
+ print_success(data, fmt=fmt)
52
+
53
+
54
+ @api_key_app.command("list")
55
+ @handle_cli_errors
56
+ def list_keys(ctx: typer.Context) -> None:
57
+ """List all API keys."""
58
+ client = ctx.obj["client"]
59
+ fmt = ctx.obj["format"]
60
+ data = client.get("/api-keys")
61
+ items = data.get("api_keys", [])
62
+ # Format scopes for table display
63
+ for item in items:
64
+ s = item.get("scopes")
65
+ item["scopes"] = ", ".join(s) if s else "all"
66
+ print_success(items, fmt=fmt, columns=_KEY_COLUMNS)
67
+
68
+
69
+ @api_key_app.command()
70
+ @handle_cli_errors
71
+ def rename(
72
+ ctx: typer.Context,
73
+ key_id: str = typer.Argument(help="API key ID"),
74
+ name: str = typer.Argument(help="New name"),
75
+ ) -> None:
76
+ """Rename an API key."""
77
+ client = ctx.obj["client"]
78
+ fmt = ctx.obj["format"]
79
+ data = client.patch(f"/api-keys/{key_id}", json={"name": name})
80
+ print_success(data, fmt=fmt)
81
+
82
+
83
+ @api_key_app.command()
84
+ @handle_cli_errors
85
+ def revoke(
86
+ ctx: typer.Context,
87
+ key_id: str = typer.Argument(help="API key ID"),
88
+ ) -> None:
89
+ """Revoke an API key."""
90
+ client = ctx.obj["client"]
91
+ fmt = ctx.obj["format"]
92
+ data = client.delete(f"/api-keys/{key_id}")
93
+ print_success(data, fmt=fmt)
@@ -0,0 +1,82 @@
1
+ """auth — login, status, logout."""
2
+
3
+ import typer
4
+
5
+ from paper_stuff_cli.errors import CLIError
6
+ from paper_stuff_cli.config import (
7
+ load_global_config,
8
+ redact_key,
9
+ save_global_config,
10
+ set_profile_value,
11
+ )
12
+ from paper_stuff_cli.output import print_error, print_message, print_success
13
+
14
+ auth_app = typer.Typer(help="Authentication management.")
15
+
16
+
17
+ @auth_app.command()
18
+ def login(
19
+ ctx: typer.Context,
20
+ api_key: str = typer.Option(..., "--api-key", help="API key (pst_...)"),
21
+ ) -> None:
22
+ """Store an API key and verify it works."""
23
+ client = ctx.obj["client"]
24
+ client._api_key = api_key
25
+ fmt = ctx.obj["format"]
26
+ quiet = ctx.obj["quiet"]
27
+ profile = ctx.obj["profile"]
28
+
29
+ try:
30
+ data = client.get("/user/settings")
31
+ set_profile_value(profile, "api_key", api_key)
32
+ set_profile_value(profile, "api_url", client.api_url)
33
+ print_message(f"Authenticated. Key stored in profile '{profile}'.", quiet=quiet)
34
+ print_success({"authenticated": True, "profile": profile}, fmt=fmt)
35
+ except CLIError as e:
36
+ print_error("UNAUTHORIZED", f"API key validation failed: {e}", fmt=fmt)
37
+ raise typer.Exit(1)
38
+
39
+
40
+ @auth_app.command()
41
+ def status(ctx: typer.Context) -> None:
42
+ """Show current authentication state."""
43
+ client = ctx.obj["client"]
44
+ fmt = ctx.obj["format"]
45
+ profile = ctx.obj["profile"]
46
+
47
+ info = {
48
+ "profile": profile,
49
+ "api_url": client.api_url,
50
+ "api_key": redact_key(client._api_key),
51
+ "jwt": "set" if client._token else "(not set)",
52
+ }
53
+
54
+ # Try to verify auth
55
+ if client._api_key or client._token:
56
+ try:
57
+ client.get("/user/settings")
58
+ info["authenticated"] = True
59
+ except CLIError:
60
+ info["authenticated"] = False
61
+ else:
62
+ info["authenticated"] = False
63
+
64
+ print_success(info, fmt=fmt)
65
+
66
+
67
+ @auth_app.command()
68
+ def logout(ctx: typer.Context) -> None:
69
+ """Clear stored credentials from the active profile."""
70
+ profile = ctx.obj["profile"]
71
+ fmt = ctx.obj["format"]
72
+ quiet = ctx.obj["quiet"]
73
+
74
+ config = load_global_config()
75
+ profiles = config.get("profiles", {})
76
+ if profile in profiles:
77
+ profiles[profile].pop("api_key", None)
78
+ profiles[profile].pop("token", None)
79
+ save_global_config(config)
80
+
81
+ print_message(f"Credentials cleared for profile '{profile}'.", quiet=quiet)
82
+ print_success({"logged_out": True, "profile": profile}, fmt=fmt)
@@ -0,0 +1,76 @@
1
+ """batch — bulk operations on multiple documents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ from paper_stuff_cli.decorators import handle_cli_errors
10
+ from paper_stuff_cli.output import print_success
11
+
12
+ batch_app = typer.Typer(help="Batch operations on documents.")
13
+
14
+
15
+ @batch_app.command()
16
+ @handle_cli_errors
17
+ def delete(
18
+ ctx: typer.Context,
19
+ ids: list[str] = typer.Argument(help="Document IDs"),
20
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation"),
21
+ ) -> None:
22
+ """Delete multiple documents."""
23
+ client = ctx.obj["client"]
24
+ fmt = ctx.obj["format"]
25
+ if not yes:
26
+ typer.confirm(f"Delete {len(ids)} documents?", abort=True)
27
+ data = client.post("/documents/batch/delete", json={"document_ids": ids})
28
+ print_success(data, fmt=fmt)
29
+
30
+
31
+ @batch_app.command("payment-status")
32
+ @handle_cli_errors
33
+ def payment_status(
34
+ ctx: typer.Context,
35
+ status: str = typer.Argument(help="PAID or UNPAID"),
36
+ ids: list[str] = typer.Argument(help="Document IDs"),
37
+ ) -> None:
38
+ """Update payment status for multiple documents."""
39
+ client = ctx.obj["client"]
40
+ fmt = ctx.obj["format"]
41
+ data = client.post(
42
+ "/documents/batch/payment-status",
43
+ json={"document_ids": ids, "payment_status": status.upper()},
44
+ )
45
+ print_success(data, fmt=fmt)
46
+
47
+
48
+ @batch_app.command()
49
+ @handle_cli_errors
50
+ def category(
51
+ ctx: typer.Context,
52
+ ids: list[str] = typer.Argument(help="Document IDs"),
53
+ main: str = typer.Option(..., "--main", help="Main category"),
54
+ sub: Optional[str] = typer.Option(None, "--sub", help="Subcategory"),
55
+ ) -> None:
56
+ """Update category for multiple documents."""
57
+ client = ctx.obj["client"]
58
+ fmt = ctx.obj["format"]
59
+ body: dict = {"document_ids": ids, "main_category": main}
60
+ if sub:
61
+ body["subcategory"] = sub
62
+ data = client.post("/documents/batch/category", json=body)
63
+ print_success(data, fmt=fmt)
64
+
65
+
66
+ @batch_app.command()
67
+ @handle_cli_errors
68
+ def review(
69
+ ctx: typer.Context,
70
+ ids: list[str] = typer.Argument(help="Document IDs"),
71
+ ) -> None:
72
+ """Mark multiple documents as reviewed."""
73
+ client = ctx.obj["client"]
74
+ fmt = ctx.obj["format"]
75
+ data = client.post("/documents/batch/review", json={"document_ids": ids})
76
+ print_success(data, fmt=fmt)
@@ -0,0 +1,28 @@
1
+ """dashboard — show dashboard summary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ from paper_stuff_cli.decorators import handle_cli_errors
10
+ from paper_stuff_cli.output import print_success
11
+
12
+ dashboard_app = typer.Typer(help="Dashboard overview.")
13
+
14
+
15
+ @dashboard_app.callback(invoke_without_command=True)
16
+ @handle_cli_errors
17
+ def dashboard(
18
+ ctx: typer.Context,
19
+ year: Optional[int] = typer.Option(None, "--year", help="Filter by year"),
20
+ ) -> None:
21
+ """Show dashboard summary."""
22
+ client = ctx.obj["client"]
23
+ fmt = ctx.obj["format"]
24
+ params: dict = {}
25
+ if year:
26
+ params["year"] = year
27
+ data = client.get("/dashboard/summary", params=params)
28
+ print_success(data, fmt=fmt)