proxcli 0.1.0__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 (49) hide show
  1. proxcli-0.2.0/.env.example +3 -0
  2. {proxcli-0.1.0 → proxcli-0.2.0}/.gitignore +3 -0
  3. {proxcli-0.1.0 → proxcli-0.2.0}/CHANGELOG.md +13 -1
  4. {proxcli-0.1.0 → proxcli-0.2.0}/PKG-INFO +4 -4
  5. {proxcli-0.1.0 → proxcli-0.2.0}/README.md +3 -3
  6. proxcli-0.2.0/TODO.md +85 -0
  7. {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/cli/container.py +3 -3
  8. {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/cli/storage.py +22 -0
  9. {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/cli/vm.py +3 -3
  10. {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/client/client.py +65 -0
  11. proxcli-0.2.0/proxmox/utils/helpers.py +32 -0
  12. {proxcli-0.1.0 → proxcli-0.2.0}/pyproject.toml +1 -1
  13. {proxcli-0.1.0 → proxcli-0.2.0}/tests/test_client.py +93 -0
  14. {proxcli-0.1.0 → proxcli-0.2.0}/uv.lock +2 -2
  15. proxcli-0.1.0/proxmox/utils/helpers.py +0 -14
  16. {proxcli-0.1.0 → proxcli-0.2.0}/.github/workflows/ci.yml +0 -0
  17. {proxcli-0.1.0 → proxcli-0.2.0}/.python-version +0 -0
  18. {proxcli-0.1.0 → proxcli-0.2.0}/PLAN.md +0 -0
  19. {proxcli-0.1.0 → proxcli-0.2.0}/PROJECT.md +0 -0
  20. {proxcli-0.1.0 → proxcli-0.2.0}/PROMPT.md +0 -0
  21. {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/__init__.py +0 -0
  22. {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/cli/__init__.py +0 -0
  23. {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/cli/auth.py +0 -0
  24. {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/cli/cluster.py +0 -0
  25. {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/cli/main.py +0 -0
  26. {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/cli/node.py +0 -0
  27. {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/cli/tasks.py +0 -0
  28. {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/client/__init__.py +0 -0
  29. {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/client/auth.py +0 -0
  30. {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/client/exceptions.py +0 -0
  31. {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/config/__init__.py +0 -0
  32. {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/config/config.py +0 -0
  33. {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/config/models.py +0 -0
  34. {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/output/__init__.py +0 -0
  35. {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/output/formatter.py +0 -0
  36. {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/output/json_fmt.py +0 -0
  37. {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/output/table_fmt.py +0 -0
  38. {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/output/yaml_fmt.py +0 -0
  39. {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/utils/__init__.py +0 -0
  40. {proxcli-0.1.0 → proxcli-0.2.0}/proxmox/utils/logging.py +0 -0
  41. {proxcli-0.1.0 → proxcli-0.2.0}/tests/__init__.py +0 -0
  42. {proxcli-0.1.0 → proxcli-0.2.0}/tests/conftest.py +0 -0
  43. {proxcli-0.1.0 → proxcli-0.2.0}/tests/test_auth.py +0 -0
  44. {proxcli-0.1.0 → proxcli-0.2.0}/tests/test_cli/__init__.py +0 -0
  45. {proxcli-0.1.0 → proxcli-0.2.0}/tests/test_cli/test_main.py +0 -0
  46. {proxcli-0.1.0 → proxcli-0.2.0}/tests/test_config.py +0 -0
  47. {proxcli-0.1.0 → proxcli-0.2.0}/tests/test_integration/__init__.py +0 -0
  48. {proxcli-0.1.0 → proxcli-0.2.0}/tests/test_output/__init__.py +0 -0
  49. {proxcli-0.1.0 → 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,6 +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.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.
15
+
16
+ ### Changed
17
+ - `proxmox vm create --vmid` and `proxmox container create --vmid` are now optional.
18
+ The next free VMID is auto-assigned via the `/cluster/nextid` API when omitted.
19
+
10
20
  ## [0.1.0] - 2026-06-20
11
21
 
12
22
  ### Added
@@ -26,4 +36,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
26
36
  - CSRF ticket auto-refresh on 401.
27
37
  - AI-agent-friendly: default JSON output, strict exit codes, `--dry-run` mode.
28
38
 
29
- [0.1.0]: https://github.com/xezpeleta/proxmox-cli/releases/tag/v0.1.0
39
+ [0.2.0]: https://github.com/xezpeleta/proxcli/releases/tag/v0.2.0
40
+ [0.1.1]: https://github.com/xezpeleta/proxcli/releases/tag/v0.1.1
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.0
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
@@ -36,7 +36,7 @@ Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
36
36
  uv tool install proxmox
37
37
 
38
38
  # From Git
39
- uv tool install git+https://github.com/xezpeleta/proxmox-cli.git
39
+ uv tool install git+https://github.com/xezpeleta/proxcli.git
40
40
 
41
41
  # From local checkout
42
42
  uv tool install .
@@ -238,8 +238,8 @@ proxmox vm show 999 || echo "VM not found"
238
238
 
239
239
  ```bash
240
240
  # Clone
241
- git clone https://github.com/xezpeleta/proxmox-cli.git
242
- cd proxmox-cli
241
+ git clone https://github.com/xezpeleta/proxcli.git
242
+ cd proxcli
243
243
 
244
244
  # Install dev dependencies
245
245
  uv sync
@@ -13,7 +13,7 @@ Requires Python 3.10+ and [uv](https://docs.astral.sh/uv/).
13
13
  uv tool install proxmox
14
14
 
15
15
  # From Git
16
- uv tool install git+https://github.com/xezpeleta/proxmox-cli.git
16
+ uv tool install git+https://github.com/xezpeleta/proxcli.git
17
17
 
18
18
  # From local checkout
19
19
  uv tool install .
@@ -215,8 +215,8 @@ proxmox vm show 999 || echo "VM not found"
215
215
 
216
216
  ```bash
217
217
  # Clone
218
- git clone https://github.com/xezpeleta/proxmox-cli.git
219
- cd proxmox-cli
218
+ git clone https://github.com/xezpeleta/proxcli.git
219
+ cd proxcli
220
220
 
221
221
  # Install dev dependencies
222
222
  uv sync
proxcli-0.2.0/TODO.md ADDED
@@ -0,0 +1,85 @@
1
+ # TODO
2
+
3
+ Planned improvements for future releases. Items are roughly ordered by priority.
4
+
5
+ ---
6
+
7
+ ## v1.1 — Polish & Usability
8
+
9
+ - [ ] **Shell completions**
10
+ - Add `proxmox completion bash|zsh|fish` subcommand that emits a completion script. Use argparse's built-in completion or a lightweight generator. Makes tab-completion work for all subcommands and flags.
11
+
12
+ - [ ] **Streaming task logs (`--follow`)**
13
+ - `proxmox task log <upid>` that streams task output in real time (like `tail -f`). Requires httpx streaming support (already viable — httpx is the chosen HTTP client).
14
+
15
+ - [ ] **Startup time optimization**
16
+ - Current `proxmox --help` takes ~350ms. Lazy-load subcommand modules so only the requested resource's code is imported. Move `import rich`, `import yaml` inside formatter functions. Target: <200ms.
17
+
18
+ - [ ] **`--output table` column selection**
19
+ - Allow `proxmox vm list --output table --columns vmid,name,status,mem` to pick which columns appear in the table.
20
+
21
+ - [ ] **Color support in table output**
22
+ - Use `rich` styling for status values (green= running, red= stopped, yellow= suspended).
23
+
24
+ ---
25
+
26
+ ## v1.2 — Resource Coverage
27
+
28
+ - [ ] **Backup (`vzdump`) management**
29
+ - `proxmox backup` subcommand: `list`, `create`, `show`, `delete`. Wrap `/nodes/{node}/vzdump` and `/nodes/{node}/storage/{storage}/content` for backup files.
30
+
31
+ - [ ] **User & permission management**
32
+ - `proxmox user` subcommand: `list`, `show`, `create`, `update`, `delete`. Wraps `/access/users`, `/access/acl`, `/access/roles`.
33
+
34
+ - [ ] **Pool management**
35
+ - `proxmox pool` subcommand: `list`, `show`, `create`, `update`, `delete`. Wraps `/pools`.
36
+
37
+ - [ ] **Network management**
38
+ - `proxmox network` subcommand: `list`, `show`, `update` for bridges, bonds, VLANs. Wraps `/nodes/{node}/network`.
39
+
40
+ - [ ] **Firewall management**
41
+ - `proxmox firewall` subcommand: `list`, `show`, `enable`, `disable`, `create rule`, `delete rule`. Wraps `/nodes/{node}/firewall`, `/cluster/firewall`.
42
+
43
+ - [ ] **SDN (Software-Defined Networking)**
44
+ - `proxmox sdn` subcommand: `zones`, `vnets`, `subnets`. Wraps `/cluster/sdn/*` endpoints.
45
+
46
+ - [ ] **HA (High Availability)**
47
+ - `proxmox ha` subcommand: `status`, `groups`, `resources`. Wraps `/cluster/ha/*` endpoints.
48
+
49
+ ---
50
+
51
+ ## v2.0 — Multi-Cluster & Advanced
52
+
53
+ - [ ] **Multi-profile / multi-cluster support**
54
+ - Support `--profile <name>` global flag to switch between multiple saved Proxmox endpoints. Config file format extended from single endpoint to a profiles dict. `proxmox auth login --profile homelab` and `proxmox auth login --profile work` coexist.
55
+
56
+ - [ ] **Batch / bulk operations**
57
+ - `proxmox vm start --all-on-node pve01` (start all VMs on a node). `proxmox vm snapshot --vmid 100,101,102` (apply to multiple IDs).
58
+
59
+ - [ ] **Config file templating**
60
+ - Ability to define VM/container specs in a YAML/JSON file and create from it: `proxmox vm create --file my-vm.yaml`.
61
+
62
+ - [ ] **Plugin system for custom commands**
63
+ - Allow users to extend the CLI with custom subcommands via a plugins directory.
64
+
65
+ - [ ] **Dry-run diff mode**
66
+ - `--dry-run` that shows what *would change* on the Proxmox side (e.g., before/after VM config diff) rather than just the HTTP request.
67
+
68
+ ---
69
+
70
+ ## Ideas (not yet scheduled)
71
+
72
+ - [ ] **Interactive mode / TUI**
73
+ - A `proxmox tui` command that opens a terminal UI (like `htop` but for Proxmox resources). Low priority — the CLI is designed for automation first.
74
+
75
+ - [ ] **Webhook / event listener**
76
+ - Subscribe to Proxmox cluster events and pipe them to a webhook or stdout for external monitoring.
77
+
78
+ - [ ] **VM migration wizard**
79
+ - `proxmox vm migrate <vmid> --to <target-node>` with progress tracking and live migration support.
80
+
81
+ - [ ] **Proxmox Backup Server (PBS) integration**
82
+ - Separate subcommand or a companion tool (`proxbackup`?) for managing PBS instances via their API.
83
+
84
+ - [ ] **Terraform / Pulumi bridge**
85
+ - Export current Proxmox state as Terraform HCL or Pulumi Python/TypeScript, enabling import into IaC.
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import argparse
6
6
 
7
7
  from proxmox.client.client import ProxmoxClient
8
- from proxmox.utils.helpers import vmid_type
8
+ from proxmox.utils.helpers import resolve_vmid, vmid_type
9
9
 
10
10
 
11
11
  def register_container_parser(subparsers: argparse._SubParsersAction) -> None:
@@ -27,7 +27,7 @@ def register_container_parser(subparsers: argparse._SubParsersAction) -> None:
27
27
  # --- container create ---
28
28
  ct_create = ct_sub.add_parser("create", help="Create a new container")
29
29
  ct_create.add_argument("--node", required=True, help="Target node")
30
- ct_create.add_argument("--vmid", type=vmid_type, required=True, help="Container ID")
30
+ ct_create.add_argument("--vmid", type=vmid_type, default=None, help="Container ID (auto-assigned if omitted)")
31
31
  ct_create.add_argument("--ostemplate", required=True, help="OS template")
32
32
  ct_create.add_argument("--storage", default=None, help="Storage for the container")
33
33
  ct_create.add_argument("--memory", type=int, default=512, help="Memory in MB")
@@ -117,7 +117,7 @@ def _ct_show(args: argparse.Namespace, client: ProxmoxClient) -> dict:
117
117
 
118
118
  def _ct_create(args: argparse.Namespace, client: ProxmoxClient) -> dict:
119
119
  data: dict = {
120
- "vmid": args.vmid,
120
+ "vmid": resolve_vmid(client, args.vmid),
121
121
  "ostemplate": args.ostemplate,
122
122
  "memory": args.memory,
123
123
  "cores": args.cores,
@@ -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
+ )
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  import argparse
6
6
 
7
7
  from proxmox.client.client import ProxmoxClient
8
- from proxmox.utils.helpers import vmid_type
8
+ from proxmox.utils.helpers import resolve_vmid, vmid_type
9
9
 
10
10
 
11
11
  def register_vm_parser(subparsers: argparse._SubParsersAction) -> None:
@@ -27,7 +27,7 @@ def register_vm_parser(subparsers: argparse._SubParsersAction) -> None:
27
27
  # --- vm create ---
28
28
  vm_create = vm_sub.add_parser("create", help="Create a new VM")
29
29
  vm_create.add_argument("--node", required=True, help="Target node")
30
- vm_create.add_argument("--vmid", type=vmid_type, required=True, help="VM ID")
30
+ vm_create.add_argument("--vmid", type=vmid_type, default=None, help="VM ID (auto-assigned if omitted)")
31
31
  vm_create.add_argument("--memory", type=int, required=True, help="Memory in MB")
32
32
  vm_create.add_argument("--cores", type=int, default=1, help="CPU cores (default: 1)")
33
33
  vm_create.add_argument("--net", default=None, help="Network config (e.g. model=virtio,bridge=vmbr0)")
@@ -141,7 +141,7 @@ def _vm_show(args: argparse.Namespace, client: ProxmoxClient) -> dict:
141
141
 
142
142
  def _vm_create(args: argparse.Namespace, client: ProxmoxClient) -> dict:
143
143
  data: dict = {
144
- "vmid": args.vmid,
144
+ "vmid": resolve_vmid(client, args.vmid),
145
145
  "memory": args.memory,
146
146
  "cores": args.cores,
147
147
  }
@@ -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
@@ -0,0 +1,32 @@
1
+ """Shared helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from proxmox.client.client import ProxmoxClient
9
+
10
+
11
+ def vmid_type(value: str) -> int:
12
+ """Argparse type for validating VMID (positive integer)."""
13
+ try:
14
+ v = int(value)
15
+ except ValueError:
16
+ raise ValueError(f"Invalid VMID '{value}': must be an integer")
17
+ if v <= 0:
18
+ raise ValueError(f"Invalid VMID '{value}': must be positive")
19
+ return v
20
+
21
+
22
+ def resolve_vmid(client: ProxmoxClient, vmid: int | None) -> int:
23
+ """Return *vmid* if provided, otherwise fetch the next free VMID from the cluster."""
24
+ if vmid is not None and vmid > 0:
25
+ return vmid
26
+ result = client.get("/cluster/nextid")
27
+ if isinstance(result, dict):
28
+ # Proxmox returns {"data": "<nextid>"} or just "<nextid>" as string
29
+ raw = result.get("data", result)
30
+ if isinstance(raw, str):
31
+ return int(raw)
32
+ return int(result) if isinstance(result, (str, int)) else 0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "proxcli"
3
- version = "0.1.0"
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 = [
@@ -8,6 +8,7 @@ import pytest
8
8
  from proxmox.client.auth import AuthManager
9
9
  from proxmox.client.client import ProxmoxClient
10
10
  from proxmox.client.exceptions import ProxmoxAPIError
11
+ from proxmox.utils.helpers import resolve_vmid
11
12
 
12
13
 
13
14
  class TestProxmoxClient:
@@ -119,3 +120,95 @@ class TestProxmoxClient:
119
120
  client = ProxmoxClient("https://pve:8006/", AuthManager())
120
121
  result = client.get("/nodes")
121
122
  assert result == []
123
+
124
+
125
+ class TestResolveVmid:
126
+ def test_returns_provided_vmid(self, mock_httpx_client):
127
+ """When a vmid is provided, return it without calling the API."""
128
+ client = ProxmoxClient("https://pve:8006", AuthManager())
129
+ result = resolve_vmid(client, 110)
130
+ assert result == 110
131
+ assert len(mock_httpx_client.get_requests()) == 0
132
+
133
+ def test_fetches_nextid_when_vmid_is_none(self, mock_httpx_client):
134
+ """When vmid is None, call /cluster/nextid."""
135
+ mock_httpx_client.add_response(
136
+ url="https://pve:8006/api2/json/cluster/nextid",
137
+ json={"data": "102"},
138
+ )
139
+ client = ProxmoxClient("https://pve:8006", AuthManager())
140
+ result = resolve_vmid(client, None)
141
+ assert result == 102
142
+ assert len(mock_httpx_client.get_requests()) == 1
143
+
144
+ def test_fetches_nextid_when_vmid_is_zero(self, mock_httpx_client):
145
+ """When vmid is 0, also fetch nextid (0 is not a valid VMID)."""
146
+ mock_httpx_client.add_response(
147
+ url="https://pve:8006/api2/json/cluster/nextid",
148
+ json={"data": "105"},
149
+ )
150
+ client = ProxmoxClient("https://pve:8006", AuthManager())
151
+ result = resolve_vmid(client, 0)
152
+ assert result == 105
153
+
154
+ def test_nextid_returns_integer(self, mock_httpx_client):
155
+ """Some Proxmox versions return nextid as integer."""
156
+ mock_httpx_client.add_response(
157
+ url="https://pve:8006/api2/json/cluster/nextid",
158
+ json={"data": 108},
159
+ )
160
+ client = ProxmoxClient("https://pve:8006", AuthManager())
161
+ result = resolve_vmid(client, None)
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
@@ -253,8 +253,8 @@ wheels = [
253
253
  ]
254
254
 
255
255
  [[package]]
256
- name = "proxmox"
257
- version = "0.1.0"
256
+ name = "proxcli"
257
+ version = "0.1.1"
258
258
  source = { editable = "." }
259
259
  dependencies = [
260
260
  { name = "httpx" },
@@ -1,14 +0,0 @@
1
- """Shared helpers."""
2
-
3
- from __future__ import annotations
4
-
5
-
6
- def vmid_type(value: str) -> int:
7
- """Argparse type for validating VMID (positive integer)."""
8
- try:
9
- v = int(value)
10
- except ValueError:
11
- raise ValueError(f"Invalid VMID '{value}': must be an integer")
12
- if v <= 0:
13
- raise ValueError(f"Invalid VMID '{value}': must be positive")
14
- return v
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