proxcli 0.1.1__tar.gz → 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 (48) hide show
  1. proxcli-0.2.0/.env.example +3 -0
  2. {proxcli-0.1.1 → proxcli-0.2.0}/.gitignore +3 -0
  3. {proxcli-0.1.1 → proxcli-0.2.0}/CHANGELOG.md +6 -1
  4. {proxcli-0.1.1 → proxcli-0.2.0}/PKG-INFO +1 -1
  5. {proxcli-0.1.1 → proxcli-0.2.0}/proxmox/cli/storage.py +22 -0
  6. {proxcli-0.1.1 → proxcli-0.2.0}/proxmox/client/client.py +65 -0
  7. {proxcli-0.1.1 → proxcli-0.2.0}/pyproject.toml +1 -1
  8. {proxcli-0.1.1 → proxcli-0.2.0}/tests/test_client.py +52 -0
  9. {proxcli-0.1.1 → proxcli-0.2.0}/uv.lock +1 -1
  10. {proxcli-0.1.1 → proxcli-0.2.0}/.github/workflows/ci.yml +0 -0
  11. {proxcli-0.1.1 → proxcli-0.2.0}/.python-version +0 -0
  12. {proxcli-0.1.1 → proxcli-0.2.0}/PLAN.md +0 -0
  13. {proxcli-0.1.1 → proxcli-0.2.0}/PROJECT.md +0 -0
  14. {proxcli-0.1.1 → proxcli-0.2.0}/PROMPT.md +0 -0
  15. {proxcli-0.1.1 → proxcli-0.2.0}/README.md +0 -0
  16. {proxcli-0.1.1 → proxcli-0.2.0}/TODO.md +0 -0
  17. {proxcli-0.1.1 → proxcli-0.2.0}/proxmox/__init__.py +0 -0
  18. {proxcli-0.1.1 → proxcli-0.2.0}/proxmox/cli/__init__.py +0 -0
  19. {proxcli-0.1.1 → proxcli-0.2.0}/proxmox/cli/auth.py +0 -0
  20. {proxcli-0.1.1 → proxcli-0.2.0}/proxmox/cli/cluster.py +0 -0
  21. {proxcli-0.1.1 → proxcli-0.2.0}/proxmox/cli/container.py +0 -0
  22. {proxcli-0.1.1 → proxcli-0.2.0}/proxmox/cli/main.py +0 -0
  23. {proxcli-0.1.1 → proxcli-0.2.0}/proxmox/cli/node.py +0 -0
  24. {proxcli-0.1.1 → proxcli-0.2.0}/proxmox/cli/tasks.py +0 -0
  25. {proxcli-0.1.1 → proxcli-0.2.0}/proxmox/cli/vm.py +0 -0
  26. {proxcli-0.1.1 → proxcli-0.2.0}/proxmox/client/__init__.py +0 -0
  27. {proxcli-0.1.1 → proxcli-0.2.0}/proxmox/client/auth.py +0 -0
  28. {proxcli-0.1.1 → proxcli-0.2.0}/proxmox/client/exceptions.py +0 -0
  29. {proxcli-0.1.1 → proxcli-0.2.0}/proxmox/config/__init__.py +0 -0
  30. {proxcli-0.1.1 → proxcli-0.2.0}/proxmox/config/config.py +0 -0
  31. {proxcli-0.1.1 → proxcli-0.2.0}/proxmox/config/models.py +0 -0
  32. {proxcli-0.1.1 → proxcli-0.2.0}/proxmox/output/__init__.py +0 -0
  33. {proxcli-0.1.1 → proxcli-0.2.0}/proxmox/output/formatter.py +0 -0
  34. {proxcli-0.1.1 → proxcli-0.2.0}/proxmox/output/json_fmt.py +0 -0
  35. {proxcli-0.1.1 → proxcli-0.2.0}/proxmox/output/table_fmt.py +0 -0
  36. {proxcli-0.1.1 → proxcli-0.2.0}/proxmox/output/yaml_fmt.py +0 -0
  37. {proxcli-0.1.1 → proxcli-0.2.0}/proxmox/utils/__init__.py +0 -0
  38. {proxcli-0.1.1 → proxcli-0.2.0}/proxmox/utils/helpers.py +0 -0
  39. {proxcli-0.1.1 → proxcli-0.2.0}/proxmox/utils/logging.py +0 -0
  40. {proxcli-0.1.1 → proxcli-0.2.0}/tests/__init__.py +0 -0
  41. {proxcli-0.1.1 → proxcli-0.2.0}/tests/conftest.py +0 -0
  42. {proxcli-0.1.1 → proxcli-0.2.0}/tests/test_auth.py +0 -0
  43. {proxcli-0.1.1 → proxcli-0.2.0}/tests/test_cli/__init__.py +0 -0
  44. {proxcli-0.1.1 → proxcli-0.2.0}/tests/test_cli/test_main.py +0 -0
  45. {proxcli-0.1.1 → proxcli-0.2.0}/tests/test_config.py +0 -0
  46. {proxcli-0.1.1 → proxcli-0.2.0}/tests/test_integration/__init__.py +0 -0
  47. {proxcli-0.1.1 → proxcli-0.2.0}/tests/test_output/__init__.py +0 -0
  48. {proxcli-0.1.1 → proxcli-0.2.0}/tests/test_output/test_formatter.py +0 -0
@@ -0,0 +1,3 @@
1
+ # PyPI publish token
2
+ # Get yours at: https://pypi.org/manage/account/token/
3
+ PYPI_TOKEN=pypi-xxxxxxxxxxxxxxxx
@@ -9,6 +9,9 @@ wheels/
9
9
  # Virtual environments
10
10
  .venv
11
11
 
12
+ # Environment / secrets
13
+ .env
14
+
12
15
  # IDE / Editor
13
16
  *.swp
14
17
  *.swo
@@ -7,7 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- ## [0.1.1] - 2026-06-20
10
+ ## [0.2.0] - 2026-06-20
11
+
12
+ ### Added
13
+ - `proxmox storage upload` command for uploading ISO, vztmpl, and import files to storage via multipart/form-data.
14
+ - `ProxmoxClient.upload()` method supporting file uploads with content type selection.
11
15
 
12
16
  ### Changed
13
17
  - `proxmox vm create --vmid` and `proxmox container create --vmid` are now optional.
@@ -32,5 +36,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
32
36
  - CSRF ticket auto-refresh on 401.
33
37
  - AI-agent-friendly: default JSON output, strict exit codes, `--dry-run` mode.
34
38
 
39
+ [0.2.0]: https://github.com/xezpeleta/proxcli/releases/tag/v0.2.0
35
40
  [0.1.1]: https://github.com/xezpeleta/proxcli/releases/tag/v0.1.1
36
41
  [0.1.0]: https://github.com/xezpeleta/proxcli/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxcli
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: A CLI tool to interact with Proxmox VE nodes and clusters via the REST API
5
5
  Author-email: Xabi Ezpeleta <xezpeleta@gmail.com>
6
6
  License: MIT
@@ -28,6 +28,19 @@ def register_storage_parser(subparsers: argparse._SubParsersAction) -> None:
28
28
  st_content.add_argument("--node", help="Node name (auto-detected if omitted)")
29
29
  st_content.set_defaults(func=_st_content)
30
30
 
31
+ # --- storage upload ---
32
+ st_upload = st_sub.add_parser("upload", help="Upload a file to storage")
33
+ st_upload.add_argument("--node", required=True, help="Target node")
34
+ st_upload.add_argument("--storage", required=True, help="Storage ID (e.g. 'local')")
35
+ st_upload.add_argument("--file", required=True, help="Path to the local file")
36
+ st_upload.add_argument(
37
+ "--content-type",
38
+ default="iso",
39
+ choices=["iso", "vztmpl", "import"],
40
+ help="Content type (default: iso)",
41
+ )
42
+ st_upload.set_defaults(func=_st_upload)
43
+
31
44
 
32
45
  def _st_list(args: argparse.Namespace, client: ProxmoxClient) -> dict | list:
33
46
  if args.node:
@@ -61,3 +74,12 @@ def _st_content(args: argparse.Namespace, client: ProxmoxClient) -> dict | list:
61
74
  if not node:
62
75
  return {"error": f"Could not determine node for storage '{args.storage_name}'"}
63
76
  return client.get(f"/nodes/{node}/storage/{args.storage_name}/content")
77
+
78
+
79
+ def _st_upload(args: argparse.Namespace, client: ProxmoxClient) -> dict:
80
+ return client.upload(
81
+ node=args.node,
82
+ storage=args.storage,
83
+ file_path=args.file,
84
+ content_type=args.content_type,
85
+ )
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import os
5
6
  import time
6
7
  from typing import Any
7
8
 
@@ -93,6 +94,70 @@ class ProxmoxClient:
93
94
  ) -> dict[str, Any] | list[Any]:
94
95
  return self.request("DELETE", path, params=params)
95
96
 
97
+ def upload(
98
+ self,
99
+ node: str,
100
+ storage: str,
101
+ file_path: str,
102
+ *,
103
+ content_type: str = "iso",
104
+ ) -> dict[str, Any]:
105
+ """Upload a file to a storage via multipart/form-data.
106
+
107
+ Args:
108
+ node: Target node name.
109
+ storage: Storage ID (e.g. 'local').
110
+ file_path: Path to the local file to upload.
111
+ content_type: Proxmox content type ('iso', 'vztmpl', 'import').
112
+ """
113
+ path = f"/nodes/{node}/storage/{storage}/upload"
114
+ full_url = f"{self._base_url}/api2/json{path}"
115
+
116
+ if self._dry_run:
117
+ print(f"POST {full_url}")
118
+ print(f"Headers: {self._auth.get_headers()}")
119
+ print(f"File: {file_path} (content={content_type})")
120
+ return {}
121
+
122
+ if not os.path.isfile(file_path):
123
+ raise FileNotFoundError(f"File not found: {file_path}")
124
+
125
+ filename = os.path.basename(file_path)
126
+ file_size = os.path.getsize(file_path)
127
+
128
+ self._debug(f"POST {full_url}")
129
+ self._debug(f" file: {file_path} ({file_size} bytes)")
130
+
131
+ headers = self._auth.get_headers()
132
+
133
+ with open(file_path, "rb") as f:
134
+ try:
135
+ resp = httpx.post(
136
+ full_url,
137
+ files={"filename": (filename, f, "application/octet-stream")},
138
+ data={"content": content_type},
139
+ headers=headers,
140
+ timeout=self._timeout,
141
+ verify=self._verify_tls,
142
+ )
143
+ except httpx.RequestError as exc:
144
+ msg = str(exc)
145
+ if "SSL" in msg or "certificate" in msg.lower():
146
+ msg += "\nHint: use --insecure to skip TLS verification"
147
+ raise ProxmoxAPIError(0, {"message": msg}, full_url) from exc
148
+
149
+ self._debug(f" ← {resp.status_code}")
150
+
151
+ if not (200 <= resp.status_code < 300):
152
+ try:
153
+ body = resp.json()
154
+ except Exception:
155
+ body = {"message": resp.text}
156
+ raise ProxmoxAPIError(resp.status_code, body, full_url)
157
+
158
+ envelope = resp.json()
159
+ return envelope.get("data", envelope)
160
+
96
161
  def set_credentials(self, username: str, password: str) -> None:
97
162
  """Store credentials for lazy / auto-refresh authentication."""
98
163
  self._username = username
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "proxcli"
3
- version = "0.1.1"
3
+ version = "0.2.0"
4
4
  description = "A CLI tool to interact with Proxmox VE nodes and clusters via the REST API"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -160,3 +160,55 @@ class TestResolveVmid:
160
160
  client = ProxmoxClient("https://pve:8006", AuthManager())
161
161
  result = resolve_vmid(client, None)
162
162
  assert result == 108
163
+
164
+
165
+ class TestStorageUpload:
166
+ def test_upload_success(self, mock_httpx_client, tmp_path):
167
+ """Upload sends multipart request and returns data."""
168
+ iso_file = tmp_path / "test.iso"
169
+ iso_file.write_bytes(b"fake iso content")
170
+
171
+ mock_httpx_client.add_response(
172
+ method="POST",
173
+ url="https://pve:8006/api2/json/nodes/pve01/storage/local/upload",
174
+ json={"data": "file test.iso uploaded"},
175
+ )
176
+ client = ProxmoxClient("https://pve:8006", AuthManager())
177
+ result = client.upload("pve01", "local", str(iso_file))
178
+ assert result == "file test.iso uploaded"
179
+
180
+ # Verify the request was multipart
181
+ requests = mock_httpx_client.get_requests()
182
+ assert len(requests) == 1
183
+ assert "multipart/form-data" in requests[0].headers.get("content-type", "")
184
+
185
+ def test_upload_dry_run(self, capsys):
186
+ """Upload in dry-run mode prints request without executing."""
187
+ client = ProxmoxClient("https://pve:8006", AuthManager(), dry_run=True)
188
+ result = client.upload("pve01", "local", "/fake/path.iso")
189
+ assert result == {}
190
+ captured = capsys.readouterr()
191
+ assert "POST https://pve:8006/api2/json/nodes/pve01/storage/local/upload" in captured.out
192
+ assert "/fake/path.iso" in captured.out
193
+
194
+ def test_upload_file_not_found(self):
195
+ """Upload raises FileNotFoundError for missing files."""
196
+ client = ProxmoxClient("https://pve:8006", AuthManager())
197
+ with pytest.raises(FileNotFoundError, match="File not found"):
198
+ client.upload("pve01", "local", "/nonexistent/file.iso")
199
+
200
+ def test_upload_error_response(self, mock_httpx_client, tmp_path):
201
+ """Upload raises ProxmoxAPIError on non-2xx response."""
202
+ iso_file = tmp_path / "test.iso"
203
+ iso_file.write_bytes(b"data")
204
+
205
+ mock_httpx_client.add_response(
206
+ method="POST",
207
+ url="https://pve:8006/api2/json/nodes/pve01/storage/local/upload",
208
+ status_code=403,
209
+ json={"message": "permission denied"},
210
+ )
211
+ client = ProxmoxClient("https://pve:8006", AuthManager())
212
+ with pytest.raises(ProxmoxAPIError) as exc_info:
213
+ client.upload("pve01", "local", str(iso_file))
214
+ assert exc_info.value.status_code == 403
@@ -254,7 +254,7 @@ wheels = [
254
254
 
255
255
  [[package]]
256
256
  name = "proxcli"
257
- version = "0.1.0"
257
+ version = "0.1.1"
258
258
  source = { editable = "." }
259
259
  dependencies = [
260
260
  { name = "httpx" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes