proxcli 0.1.1__tar.gz → 0.2.1__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.
- proxcli-0.2.1/.env.example +3 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/.gitignore +3 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/CHANGELOG.md +12 -1
- {proxcli-0.1.1 → proxcli-0.2.1}/PKG-INFO +1 -1
- {proxcli-0.1.1 → proxcli-0.2.1}/proxmox/cli/main.py +2 -1
- {proxcli-0.1.1 → proxcli-0.2.1}/proxmox/cli/storage.py +22 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/proxmox/client/client.py +65 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/pyproject.toml +1 -1
- {proxcli-0.1.1 → proxcli-0.2.1}/tests/test_client.py +52 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/uv.lock +1 -1
- {proxcli-0.1.1 → proxcli-0.2.1}/.github/workflows/ci.yml +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/.python-version +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/PLAN.md +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/PROJECT.md +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/PROMPT.md +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/README.md +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/TODO.md +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/proxmox/__init__.py +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/proxmox/cli/__init__.py +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/proxmox/cli/auth.py +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/proxmox/cli/cluster.py +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/proxmox/cli/container.py +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/proxmox/cli/node.py +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/proxmox/cli/tasks.py +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/proxmox/cli/vm.py +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/proxmox/client/__init__.py +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/proxmox/client/auth.py +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/proxmox/client/exceptions.py +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/proxmox/config/__init__.py +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/proxmox/config/config.py +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/proxmox/config/models.py +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/proxmox/output/__init__.py +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/proxmox/output/formatter.py +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/proxmox/output/json_fmt.py +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/proxmox/output/table_fmt.py +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/proxmox/output/yaml_fmt.py +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/proxmox/utils/__init__.py +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/proxmox/utils/helpers.py +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/proxmox/utils/logging.py +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/tests/__init__.py +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/tests/conftest.py +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/tests/test_auth.py +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/tests/test_cli/__init__.py +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/tests/test_cli/test_main.py +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/tests/test_config.py +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/tests/test_integration/__init__.py +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/tests/test_output/__init__.py +0 -0
- {proxcli-0.1.1 → proxcli-0.2.1}/tests/test_output/test_formatter.py +0 -0
|
@@ -7,7 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
-
## [0.
|
|
10
|
+
## [0.2.1] - 2026-06-20
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- `--version` now reads from installed package metadata (`importlib.metadata`) instead of a hardcoded string.
|
|
14
|
+
|
|
15
|
+
## [0.2.0] - 2026-06-20
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- `proxmox storage upload` command for uploading ISO, vztmpl, and import files to storage via multipart/form-data.
|
|
19
|
+
- `ProxmoxClient.upload()` method supporting file uploads with content type selection.
|
|
11
20
|
|
|
12
21
|
### Changed
|
|
13
22
|
- `proxmox vm create --vmid` and `proxmox container create --vmid` are now optional.
|
|
@@ -32,5 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
32
41
|
- CSRF ticket auto-refresh on 401.
|
|
33
42
|
- AI-agent-friendly: default JSON output, strict exit codes, `--dry-run` mode.
|
|
34
43
|
|
|
44
|
+
[0.2.1]: https://github.com/xezpeleta/proxcli/releases/tag/v0.2.1
|
|
45
|
+
[0.2.0]: https://github.com/xezpeleta/proxcli/releases/tag/v0.2.0
|
|
35
46
|
[0.1.1]: https://github.com/xezpeleta/proxcli/releases/tag/v0.1.1
|
|
36
47
|
[0.1.0]: https://github.com/xezpeleta/proxcli/releases/tag/v0.1.0
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import argparse
|
|
6
6
|
import os
|
|
7
7
|
import sys
|
|
8
|
+
from importlib.metadata import version
|
|
8
9
|
from typing import Any
|
|
9
10
|
|
|
10
11
|
from proxmox.client.auth import AuthManager
|
|
@@ -48,7 +49,7 @@ def build_root_parser() -> argparse.ArgumentParser:
|
|
|
48
49
|
)
|
|
49
50
|
parser.add_argument("--verbose", action="store_true", help="Enable debug output to stderr")
|
|
50
51
|
parser.add_argument(
|
|
51
|
-
"--version", action="version", version="proxmox
|
|
52
|
+
"--version", action="version", version=f"proxmox {version('proxcli')}"
|
|
52
53
|
)
|
|
53
54
|
|
|
54
55
|
subparsers = parser.add_subparsers(dest="resource", title="resources", required=False)
|
|
@@ -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
|
|
@@ -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
|
|
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
|
|
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
|