Compare commits

...
21 Commits
Author SHA1 Message Date
DuckbootandClaude Opus 5.5 890b6b115b Fix the inode count in pct-shrink-rootfs
CI / pytest and ruff (push) Successful in 5m6s
GNU df refuses -i together with --output, so the script stopped right
after mounting the container ("options -i and --output are mutually
exclusive"). The iused field needs no -i: use df --output=iused.

The df stub in the tests accepted the combination and hid the bug; it now
rejects it the way GNU df does.

Generated with AI

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 13:10:42 +01:00
DuckbootandClaude Opus 5.5 792667897a Add a node script that shrinks a container's root disk
CI / pytest and ruff (push) Successful in 5m19s
scripts/pct-shrink-rootfs runs as root on the Proxmox node and keeps
the VMID. Without --yes it only measures and prints the plan.

It adds a temporary mp9 of the new size (Proxmox allocates and formats
it), rsyncs the root disk into it, and swaps it into rootfs under
Proxmox's config flock. The config is written like
file_set_contents does on pmxcfs (tmp file, then rename), after
checking that rootfs and mp9 did not change during the copy. The old
volume is kept as the first free unusedK and the exact delete command
is printed; pct rescan is not used, so other orphans of the VMID are
not pulled in.

It refuses snapshots and pending sections, a lock, template,
protection, HA, replication, any mount point, and sizes the data does
not fit (10 % headroom on top of the ext4 reserve and metadata, and an
inode check). On an rsync failure the new volume is freed by its volid.

Tests run the real script against stub pct, df, rsync, flock,
ha-manager, pvesr and id. README documents both shrink routes.

Design: docs/plans/lxc-shrink.md (section B).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:58:02 +01:00
DuckbootandClaude Opus 5.5 82902edfc8 Restore a container backup with a smaller root disk
Proxmox only grows disks. The restore dialog gets an optional "Root
disk GiB" field for container backups; with a size, the backup is
restored as a new guest with --rootfs <storage>:<N> (Proxmox's advanced
restore mode).

- A smaller disk is only allowed for a new VMID, checked in the dialog
  and again in cli.restore(): with force, Proxmox deletes the old disks
  before it unpacks, and a failed unpack then removes the guest.
- The size must be a positive integer (0 gives an unlimited subvol on
  ZFS/dir storage) and above the used root disk space of a running
  guest.
- The confirm screen is a separate DANGER screen with a red border: it
  says mount points are dropped and their backed-up files land in the
  root disk, raw lxc.* lines can come back under root@pam, root disk
  options are not carried over, and the used space is no fit check.
- Never --unique or ignore-unpack-errors.

Design: docs/plans/lxc-shrink.md (section A).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:58:02 +01:00
DuckbootandClaude Opus 5.5 9b472e8cf6 Wrap the key bindings bar over several lines
CI / pytest and ruff (pull_request) Successful in 4m43s
CI / pytest and ruff (push) Successful in 4m43s
Textual's Footer is a single horizontal row, so on a narrow terminal the
keys at the end were cut off. KeyBar replaces it on every screen: it reads
the same active bindings, packs them greedily into rows that fit the width,
and grows in height as needed.

Generated with AI

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 08:35:03 +01:00
DuckbootandClaude Opus 5.5 0df928ab11 Restore a guest from a backup in the info screen
CI / pytest and ruff (pull_request) Successful in 7m25s
CI / pytest and ruff (push) Successful in 6m56s
r on the info screen restores the backup under the cursor of the
backup list.

- New guest (default): the next free VMID from `get cluster nextid`,
  editable. The original is left untouched. An id already used by a
  listed guest is refused.
- Overwrite: replaces the guest itself. The guest must be stopped; its
  live status is read at apply and again right before the call. The
  VMID must be typed to confirm, then an "Overwrite" confirm follows.

--storage is always passed. It is preselected to the storage of the
guest's rootfs or first disk, and the choices are the node's storages
that can hold CT (rootdir) or VM (images) disks. Start after restore
is off by default, because a copy keeps the original's MAC address
and IP.

Safety:
- cli.restore refuses force (overwrite) for any VMID other than the
  guest's own (explicit raise). A hypothesis property checks that no
  other VMID ever reaches the CLI with force.
- The volid and storage are validated before they become arguments.
- A backup of the other guest type is refused. A reused VMID can carry
  a qemu backup for a CT or the other way round, and whether PVE
  rejects a mismatched archive before removing the old guest is not
  verified.
- An earlier guest's backup (visible with a) is labelled with that
  guest's name in the dialog and the confirm.
- r before the info screen has loaded says "still loading".
- Restore gets a 1-hour CLI timeout.

Not run live: a live test would create a guest that then has to be
deleted, which the issue reserves for the user's approval. A
read-only check opened the dialog for CT 104 (next id 105, local-lvm
preselected) and closed it without any call.

Closes #15

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 01:32:05 +01:00
DuckbootandClaude Opus 5.5 262441916c Run fstrim in a VM via the guest agent
CI / pytest and ruff (pull_request) Successful in 5m56s
CI / pytest and ruff (push) Successful in 6m14s
f on the info screen runs fstrim in a running VM with the guest agent
enabled. A container gets an info note (no guest agent; use pct
fstrim on the node). A stopped VM or one without an agent gets a
warning, and nothing runs. Otherwise a confirm comes first, then
agent ping and fstrim. The result reports the trimmed total
(GiB/MiB), the number of paths and any per-path errors.

Review fixes:
- Odd agent output no longer turns a finished trim into "fstrim
  failed". Unreadable output gives "no results". Odd entries are
  repaired: non-dict entries are dropped, a non-int trimmed becomes 0
  and a missing path becomes "?". Well-formed entries are returned
  unchanged.
- fstrim gets a 30-minute CLI timeout instead of 330 s, because a big
  disk can take longer.
- Notifications are never parsed as markup (GuestApp.notify defaults
  to markup=False). Guest names, paths and CLI or agent errors are
  untrusted text, so a "[/b]" in them could break the toast.
- f before the info screen has loaded says "still loading" instead of
  a wrong "not running".
- "1 path" in the singular.

Not run live: the issue asks for approval before fstrim on VM 108.
Only the harmless agent ping was run there, and it answered.

Closes #19

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 01:08:30 +01:00
DuckbootandClaude Opus 5.5 f5f3effce5 Edit autostart, startup order and tags from the info screen
CI / pytest and ruff (pull_request) Successful in 5m31s
CI / pytest and ruff (push) Has been cancelled
The info screen's Resources table shows Autostart (yes/no), Startup
(order, up and down delays) and Tags. o opens a dialog to change them.
Only keys that differ are sent, in one `set <kind> config` call
through the existing set_net. A confirm lists the old and new value
of each key.

Tags are removed with `--delete tags` on set config. The CLI also has
a `delete ct|vm ... tags` command, but it shares the form that
destroys the whole guest, so the app never uses it; nothing in the
code builds a delete ct|vm argv. Inputs are validated before sending:
startup values must be ints >= 0, and tags must match
[A-Za-z0-9_][A-Za-z0-9_+.-]*. They are split on ; , or whitespace and
deduplicated.

Live test on CT 104: onboot 1 -> 0, startup order=5,up=10, tag
tui-test added, then all set back through the dialog. The config
ended identical to the baseline.

Closes #18

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:55:02 +01:00
DuckbootandClaude Opus 5.5 d97ad22c78 Add search and filter to the guest table
CI / pytest and ruff (pull_request) Successful in 4m48s
CI / pytest and ruff (push) Has been cancelled
/ opens a filter box under the guest table, and the table filters as
you type. Terms are separated by spaces, all must match, and case is
ignored:

- a plain term matches part of the name, the VMID or a tag
- is:running and is:stopped match the status
- type:lxc/ct and type:vm/qemu match the guest type

Enter keeps the filter and goes back to the table. Escape clears it,
from the box or from the table. The frame shows
"· filter: <text> · N of M". Actions only ever see the visible rows,
and the filter survives reloads and context switches.

Review fixes:
- Typing "[/b]" or "[/]" crashed the app, because the filter text went
  into the border subtitle, which Textual parses as markup. The
  subtitle is now plain text, so "[red]" is also shown literally.
- Escape on the table now clears a filter that was applied with Enter.
- guest_matches tolerates a None status or type.

Read-only live check on mainframe: "debian" gives 104, "type:vm" gives
108, "community-script is:running" gives 14 of 19, and "is:stopped"
gives 0 of 19.

Closes #16

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:42:13 +01:00
DuckbootandClaude Opus 5.5 ff6345c80f Document installing cv4pve-tui as a uv tool
CI / pytest and ruff (pull_request) Successful in 4m40s
CI / pytest and ruff (push) Has been cancelled
The README gets an "Install" section: install with uv tool from the
Gitea repo or from a local checkout, then upgrade and uninstall. The
"Run" section says to use `cv4pve-tui` when installed and
`uv run cv4pve-tui` from a checkout.

Verified by installing into throwaway tool dirs (UV_TOOL_DIR and
UV_TOOL_BIN_DIR under /tmp): `uv tool install .` gave a cv4pve-tui
binary. Started from /tmp in a pseudo-terminal, it drew the guest
frame and ran until it was stopped. The pyproject metadata (entry
point, readme, requires-python) was already enough.

Closes #17

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:31:01 +01:00
DuckbootandClaude Opus 5.5 e7569a7bf7 Warn on an untested cv4pve-cli version at startup
CI / pytest and ruff (pull_request) Successful in 4m42s
CI / pytest and ruff (push) Has been cancelled
At startup the app runs `cv4pve-cli --version`. If it differs from
cli.TESTED_VERSION (2.4.0), a warning says which version was found
and which one was tested. The app parses output formats that are not
a stable API: the config list text, prune-backups as a JSON string,
the CT ip-addresses JSON string and the exit codes with --wait. A
newer cv4pve-cli could break them in confusing ways.

If cv4pve-cli cannot be run at all, an error says so once, instead of
every later call failing without explanation. The README notes the
tested version.

Closes #14

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:30:14 +01:00
DuckbootandClaude Opus 5.5 364f8e2137 Show pending node package updates (read-only)
CI / pytest and ruff (pull_request) Successful in 4m47s
CI / pytest and ruff (push) Successful in 4m22s
U opens "Package updates": the pending apt updates of every online
node, with node, package, current and new version, origin and
priority. A node that fails is shown as "<node>: unavailable", and the
other nodes are still listed. A hint says the list is as of the
node's last apt update; this call does not refresh it.

The main frame's bottom border shows "· N package updates" after the
backup count, with a singular for 1. The count is fetched on mount,
after a context switch and whenever the updates screen loads, not on
every guest reload, since it costs one CLI call per node. It uses the
same generation guard as the guest list, so a count from the old
cluster is dropped after a switch.

Running apt update or upgrade stays out of scope; it is a write.

Read-only live check: "mainframe · 23 package updates", and 23 rows
in the screen.

Closes #13

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:14:07 +01:00
DuckbootandClaude Opus 5.5 1ed5e5069b Add the task log: recent tasks with status, Enter shows the log
CI / pytest and ruff (pull_request) Successful in 4m10s
CI / pytest and ruff (push) Successful in 4m32s
l on the main screen opens "Tasks", the recent cluster tasks newest
first. l on the info screen shows only that guest's tasks. Columns:
start, duration (or "running"), node, type, VMID ("-" for node
tasks), user and status. A failed task gets a "!" in the leading
column. Enter opens the task's log in a plain-text widget, so the
output is never parsed as markup.

`task list -o json` has no start or end times, so the list comes from
`/cluster/tasks`. The upid is validated (it must start with "UPID:")
before it goes in as a positional argument.

Review fixes:
- A finished task without a start time no longer crashes the app; its
  duration shows "-".
- "running" now means there is no endtime. A finished task with an
  empty status shows "unknown" and is flagged.

Read-only live check: 25 tasks listed, and the log of the newest
(vzdelsnapshot 104) ends with TASK OK.

Closes #10

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 00:03:58 +01:00
DuckbootandClaude Opus 5.5 eec65e0585 Show live usage: CPU % and Mem % in the table, in-use on the info screen
CI / pytest and ruff (pull_request) Successful in 3m49s
CI / pytest and ruff (push) Has been cancelled
The guest table gets two last columns, CPU % (cpu * 100) and Mem %
(mem / maxmem), each with one decimal. They show "-" for a guest that
is not running or lacks the field.

The info screen's Resources table gets a header and an "In use"
column: CPU %, memory MiB with %, swap for containers, and rootfs used
GiB with % for containers. VM disks show "-", because the API reports
0 without guest-agent disk info. The info screen reads status and
usage in one status/current call (new cli.current).

After an action, the acted guest's status and usage both come from
status/current, so a guest that has just started no longer shows the
lagging listing's 0.0 %.

One blind-written test expected a VM's CPU cell to read "4 cores". VMs
have always shown sockets ("4 cores × 1 socket", as the resources
spec and its tests define), so that expectation was corrected.

Closes #12

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:52:32 +01:00
DuckbootandClaude Opus 5.5 8c2b48a04c Add CI on Gitea Actions: pytest and ruff on every PR
CI / pytest and ruff (pull_request) Successful in 3m53s
CI / pytest and ruff (push) Has been cancelled
.gitea/workflows/ci.yaml runs on PRs into main and on pushes to main.
It installs uv, runs `uv sync --locked` with Python 3.12 (the lowest
version requires-python allows), then pytest, `ruff check` and
`ruff format --check`.

ruff is pinned in the dev group (0.17.3). The repo has no ruff config,
so the rules are ruff's defaults for that version; pinning makes CI
and local runs check the same rules. The setup follows the workflow
that already runs green on this Gitea instance (ubuntu-latest, pinned
checkout SHA, uv via the official script). The README gets a CI badge
and the lint commands.

Closes #11

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:36:25 +01:00
DuckbootandClaude Opus 5.5 ec35a4c996 Add snapshots to the info screen: take, roll back, delete
A "Snapshots" section on the info screen lists the snapshots, newest
first, without Proxmox's "current" pseudo entry. Keys:

- p takes a snapshot. The name defaults to tui_YYYYMMDD_HHMM, the
  description is optional, and VMs can include RAM.
- R rolls back to the snapshot under the cursor. The confirm names the
  snapshot and its date. A running guest is started again; its status
  is read right before the rollback, and if it cannot be read the
  rollback does not run.
- D deletes the snapshot under the cursor, after a confirm.

`delete ct|vm <node> <vmid> -y` without the snapshot subcommand
destroys the whole guest. delete_snapshot therefore validates the name
first (letter first, then [A-Za-z0-9_-], not "current"), writes
"snapshot" into the argv as a literal, and checks the argv shape
before running it. A property test covers rejected names.

The info screen now scrolls, because four sections do not fit short
terminals. A description starting with "-" is refused, since it is a
positional argument and could be read as an option.

Live test on CT 104: take, roll back (started again) and delete. The
snapshot list, status, config and IPv4/IPv6 match the baseline.

Closes #8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:26:49 +01:00
DuckbootandClaude Opus 5.5 520de16cc6 Flag guests that no backup job covers
The guest table marks with "!" each guest that Proxmox's not-backed-up
list returns. The frame's bottom border shows the count, e.g.
"mainframe · 2 without backup job". This uses Proxmox's own answer, so
it also counts jobs that select guests by pool.

If the coverage call fails, the guests are still listed and a warning
says the coverage is unknown.

Closes #9

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:02:35 +01:00
DuckbootandClaude Opus 5.5 5a3ca5a216 Show configured resources on the info screen
A new "Resources" section at the top of the info screen shows CPU
cores (with sockets for VMs), memory, swap for containers, and each
disk with its size. The disks are the same ones the edit dialog
offers, so cloud-init and CD drives are left out. A container without
a cores limit shows "unlimited".

The open info screen reloads after any guest job, so values edited
with e show up straight away.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:42:38 +01:00
DuckbootandClaude Opus 5.5 0c3669271c Open the info screen with Enter and move edit into it
Enter on a guest now opens its info screen, replacing i. The guest
table shows Enter as "Info" in the footer. A priority binding at app
level would have taken Enter away from buttons and lists in the
dialogs, so the hint comes from the table's own binding instead.

Editing cores, memory and disk moved from the main screen into the
info screen (key e). When the edit dialog closes, you are back on the
info screen. Test helpers that pressed i or e on the main screen now
go through Enter, because the requirement changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:34:13 +01:00
DuckbootandClaude Opus 5.5 9ff00e425f Show all dialogs and the guest table as titled boxes
The info screen's style now applies everywhere. The guest table sits
in a "Guests" frame with the active context in its bottom border.
Every dialog is a centred box with a title, labelled fields and the
buttons bottom right: confirm (titled by the action), edit resources,
switch context, back up and edit network.

Fixed:
- The network dialog hid most of its fields. In Horizontal rows
  Textual gave Select a width of 1 and Input 100 %, which pushed
  bridge, firewall, the IP mode selects and the gateways off-screen.
  Every field in a row now has an explicit width.
- A guest name or note containing markup, such as "a[/b]", crashed the
  guest table and the dialogs, because DataTable cells and border
  titles were parsed as markup. They are now plain text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:18:10 +01:00
DuckbootandClaude Opus 5.5 62490af913 Separate backups and network on the info screen
Put backups and network in their own bordered sections titled
"Backups" and "Network". Inside Network, the configured settings and
the addresses in use are labelled "Configured" and "In use". The
"unavailable" note is hidden when it is empty, so it no longer leaves
a blank line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:53:00 +01:00
DuckbootandClaude Opus 5.5 5038148686 Add network editing with VLAN, firewall, IPv4/IPv6 and undo
Press n in the info screen to edit one interface: bridge, VLAN tag,
firewall, IPv4 (DHCP, static or none) and IPv6 (SLAAC, DHCP, static or
none). The netN string is parsed, the chosen fields are changed and
the string is written back, so hwaddr, model=MAC, mtu, rate, trunks
and type are kept. For VMs, IP settings go to cloud-init ipconfigN and
only when the VM has a cloud-init drive.

A confirm shows the old and new raw values before anything is sent.
Press u to restore the values from before the last change to that
guest in this session.

Review fixes:
- Undo entries are cleared on a context switch, because VMIDs repeat
  across clusters.
- has_cloudinit only looks at disks.
- CIDR needs a decimal prefix: no netmask form and no /0.
- Fields you did not touch are not rewritten.

Live test on CT 104: firewall, VLAN 99, IPv6 DHCP and static IPv4 were
each applied and undone, and net0 ended identical to the baseline.
Going from static back to DHCP left the CT without IPv4 until a
reboot, so the confirm now warns that IP changes on a running
container may need one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:45:06 +01:00
13 changed files with 8171 additions and 123 deletions

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
name: CI
# Tests and lint for every PR into main, and a status for each commit on main.
on:
push:
branches: [main]
pull_request:
branches: [main]
# Reads the repository only.
permissions:
contents: read
# A newer push to the same ref makes an in-flight run obsolete.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: pytest and ruff
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
# Same uv as local development; the official script keeps the job free of
# third-party actions beyond checkout.
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/0.13.6/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
# --locked: fail if uv.lock does not match pyproject.toml. Python 3.12 is the
# lowest version requires-python allows.
- name: Install dependencies
run: uv sync --locked --python 3.12
# The tests mock cv4pve-cli: no binary and no cluster access needed.
- name: Tests
run: uv run pytest -q
# ruff is pinned in the dev group, so CI checks the same rules as local runs
# (no config file: ruff's own defaults for that version).
- name: Lint
run: uv run ruff check src tests
- name: Format
run: uv run ruff format --check src tests
+1
View File
@@ -2,3 +2,4 @@
__pycache__/
*.pyc
.pytest_cache/
.claude/scratch/
+76 -5
View File
@@ -1,14 +1,35 @@
# cv4pve-cli-tui
[![CI](https://git.duckboot.net/Duckboot/cv4pve-cli-tui/actions/workflows/ci.yaml/badge.svg)](https://git.duckboot.net/Duckboot/cv4pve-cli-tui/actions)
Textual TUI for [cv4pve-cli](https://github.com/Corsinvest/cv4pve-cli): list Proxmox VE
guests (VMs and LXC), start/shutdown/stop/reboot them, and change cores, memory and
disk size.
disk size. The table shows CPU % and Mem % of running guests and the info screen shows
in-use values (refresh with `g`).
It shells out to `cv4pve-cli` and uses its current context (`cv4pve-cli config use <name>`);
it stores no credentials itself. Tested with cv4pve-cli 2.4.0.
it stores no credentials itself. Tested with cv4pve-cli 2.4.0; with another version the app
warns at startup, because it parses output formats that are not a stable API.
## Install
Needs [uv](https://docs.astral.sh/uv/) and a working `cv4pve-cli` on `PATH`.
```sh
uv tool install git+https://git.duckboot.net/Duckboot/cv4pve-cli-tui
cv4pve-tui # from any directory
uv tool upgrade cv4pve-tui # later: get the newest main
uv tool uninstall cv4pve-tui
```
From a local checkout instead: `uv tool install .` (add `--force` to reinstall after
changes).
## Run
Installed: `cv4pve-tui`. From a checkout without installing:
```sh
uv run cv4pve-tui
```
@@ -19,26 +40,76 @@ uv run cv4pve-tui
| `S` | shutdown (confirm) |
| `x` | stop - hard power-off (confirm) |
| `r` | reboot (confirm) |
| `e` | edit cores / memory / grow a disk (disk growth asks again; it cannot be undone) |
| `i` | info: backups, backup job, network (config and runtime IPs); `b` there = backup now (snapshot, zstd, storage choice); `a` there = also show backups of earlier guests that had the same VMID |
| `Enter` | info: resources (CPU, memory, swap, disks), backups, backup job, network (config and runtime IPs) |
| `Enter` then `e` | in info: edit cores / memory / grow a disk (disk growth asks again; it cannot be undone) |
| `Enter` then `b` | in info: backup now (snapshot, zstd, storage choice); `a` there = also show backups of earlier guests that had the same VMID |
| `Enter` then `n` | in info: edit network (bridge, VLAN, firewall, IPv4/IPv6; VM IP via cloud-init at next boot); `u` there = undo the last network change of this guest (this session only) |
| `Enter` then `o` | in info: edit autostart, startup order (order, up and down delay) and tags (one confirmed `set config` call; empty fields remove the setting) |
| `Enter` then `p` / `R` / `D` | in info: take a snapshot (name, description, VM: include RAM) / roll back to the snapshot under the cursor (confirms; starts the guest again if it is running) / delete the snapshot under the cursor (confirms) |
| `Enter` then `f` | in info: fstrim in a running VM with the guest agent enabled (confirms; containers: use `pct fstrim` on the node) |
| `Enter` then `r` | in info: restore the backup under the cursor, as a new VMID (default, the original stays untouched; a copy keeps the original's network settings, so leave Start off until they are changed) or overwriting the guest itself (stopped guests only; type the VMID, then confirm); container backups: optional smaller root disk, see *Shrinking a container's root disk* |
| `t` | shell in a running LXC container (`ssh -t root@<node> pct enter <vmid>`) |
| `l` | tasks: recent cluster tasks (start, duration, node, type, VMID, user, status; `!` marks failures); `Enter` shows the log of the task under the cursor, `g` reloads. In info: `l` = only this guest's tasks |
| `U` | package updates: pending updates per online node (node, package, current, new, origin, priority), read-only; `g` reloads. The list is as of the node's last `apt update`. The frame's bottom border shows the count |
| `g` | refresh |
| `/` | filter the guest table as you type: space-separated terms, all must match (case-insensitive); plain term = part of name, VMID or a tag; `is:running` / `is:stopped`; `type:lxc` (or `ct`) / `type:vm` (or `qemu`). `Enter` keeps the filter and returns to the table, `Escape` clears it. The frame's bottom border shows `filter: <text> · N of M` |
| `c` | switch cv4pve-cli context (cluster) |
| `q` | quit |
Warning: link changes (bridge, VLAN, firewall) apply immediately on a running guest and can cut its network (and DNS for others if the guest is your DNS server). IP changes on a running container may need a reboot of the container (seen: static -> DHCP gave no IPv4 until a reboot).
The shell key needs key-based root SSH to the PVE node (LXC only).
Switching context runs `cv4pve-cli config use`, which changes cv4pve-cli's global current
context: other shells and scripts follow, and it stays after the app exits.
A `!` in the first column marks a guest that no backup job covers (Proxmox's
`not-backed-up` list); the frame's bottom border shows how many.
Empty fields in the edit dialog are left unchanged. If a CPU/memory change cannot be
hot-plugged, the app tells you it applies at the next reboot.
## Shrinking a container's root disk
Proxmox only grows disks. Two ways to get a smaller container root disk, both
**dangerous**; take a backup first and test on a throwaway container.
**In the TUI** (`Enter`, then `r` on a container backup): fill in *Root disk GiB*.
This restores the backup as a **new** guest with a new, smaller root disk
(Proxmox's "advanced restore mode"); overwriting the original with a smaller disk is
refused, because Proxmox deletes the old disks before it unpacks the backup. In this
mode every mount point (`mpN`) of the backup is dropped: files of mount points with
`backup=1` land inside the root disk, `backup=0` ones are not in the backup at all.
Raw `lxc.*` lines (bind mounts too) can come back if cv4pve-cli runs as root@pam, so
do not run both guests at once. A disk that is too small makes the restore fail; the
original is untouched.
**On the node** (root shell, container stopped, keeps the VMID):
```sh
scripts/pct-shrink-rootfs <vmid> <new-size-GiB> # measure and print the plan
scripts/pct-shrink-rootfs --yes <vmid> <new-size-GiB> # do it
```
It adds a temporary `mp9` of the new size (Proxmox allocates and formats it), copies
the root disk into it with rsync, then swaps it into `rootfs` under Proxmox's config
lock. The old root disk stays as an `unusedK` volume until you delete it with the
exact `pct set <vmid> --delete unusedK` the script prints; the old config is copied to
`/root/pct-shrink-<vmid>-<timestamp>.conf`. It refuses containers with snapshots,
pending changes, a lock, template or protection flag, HA, replication, or any mount
point (`mpN`, `lxc.mount.entry`), and sizes the data does not fit with headroom
(5 % ext4 reserve, metadata, inodes). chattr flags (`+i`, `+a`) are not copied. Even
the dry run mounts the container briefly (`pct mount`). Design:
`docs/plans/lxc-shrink.md`.
## Development
```sh
uv sync
uv run pytest
uv run ruff check src tests && uv run ruff format --check src tests
```
Tests mock `cv4pve-cli`; they never touch a cluster. Design: `docs/plans/guest-tui-mvp.md`.
Tests mock `cv4pve-cli`; they never touch a cluster. CI (`.gitea/workflows/ci.yaml`) runs
the same commands on every PR into `main`; ruff is pinned in the dev group so local runs
and CI check the same rules. Design: `docs/plans/guest-tui-mvp.md`.
+449
View File
@@ -346,3 +346,452 @@ Observed CLI behaviour (cv4pve-cli 2.4.0, all read-only probes):
- [x] Read-only live check: open InfoScreen for CT 104 and VM 108, data shown.
- [x] User tests "Backup now" on CT 104 (`debian (manual)`, 2026-11-02 21:09).
- [ ] Push `feat/guest-info`, merge to `main` (user-approved after the test).
---
# Network editing - delivery B
## Problem
Change a guest's network settings from the info screen: bridge, VLAN, firewall, IPv4
and IPv6 (incl. SLAAC), without breaking what is not edited.
## Decisions (user-approved)
- VM IP settings go through cloud-init `ipconfigN`, only when the VM has a cloud-init
drive; they apply at the next boot. VMs without cloud-init: IP fields disabled with a
note (IP is configured inside the VM).
- IPv4 modes: DHCP / Static (CIDR + optional gateway) / None.
IPv6 modes: SLAAC / DHCP / Static (CIDR + optional gateway) / None.
- Undo key `u`: restores the values from before the last network change made in this
app session for that guest.
- Out of scope: adding/removing interfaces, MAC, mtu, rate, trunks, DNS.
API facts (Proxmox VE 9.2, `api usage ... config set -v`):
- LXC `net[n]`: `name=<string>[,bridge][,firewall=<1|0>][,gw=<IPv4>][,gw6=<IPv6>]
[,host-managed][,hwaddr][,ip=<IPv4/CIDR|dhcp|manual>][,ip6=<IPv6/CIDR|auto|dhcp|manual>]
[,link_down][,mtu][,rate][,tag=<int>][,trunks][,type=veth]`. `ip6=auto` = SLAAC.
Applied immediately on a running CT.
- VM `net[n]`: `[model=]<enum>[,bridge][,firewall=<1|0>][,link_down][,macaddr][,mtu]
[,queues][,rate][,tag=<int>][,trunks][,<model>=<macaddr>]`. Applied immediately.
- VM `ipconfig[n]`: `[gw=<IPv4>][,gw6=<IPv6>][,ip=<IPv4/CIDR|dhcp>][,ip6=<IPv6/CIDR|dhcp|auto>]`
(cloud-init; "no explicit gateway with dhcp"). Applies at the next boot.
- `delete=<list>` removes settings (used when `ipconfigN` ends up empty).
- `get node network <node> -o json` -> list of dicts with `iface`, `type`
(`bridge`, `OVSBridge`, `eth`, ...), `active`.
- Cloud-init drive: a disk value containing `cloudinit`
(`ide2 = local-lvm:vm-108-cloudinit,media=cdrom`).
### `cli.py`
- `bridges(guest) -> list[str]` - `iface` of entries with type `bridge` or `OVSBridge`
on the guest's node, sorted.
- `format_net(d: dict) -> str` - inverse of `parse_net`: `k=v` joined by `,`, order
kept. `format_net(parse_net(s)) == s` for the real samples.
- `has_cloudinit(config) -> bool`.
- `apply_link(net, bridge, tag, firewall) -> dict` - a copy of `net`: `bridge` set;
`tag` int 1-4094 or None (None removes `tag`); `firewall` True -> `"1"`, False ->
`firewall` removed. Existing keys keep their position; new keys are appended.
- `ip_mode(cfg, family) -> tuple[str, str, str]` - `(mode, cidr, gw)` read from a
parsed LXC net or VM ipconfig dict; family 4 uses `ip`/`gw`, 6 uses `ip6`/`gw6`.
Modes: `dhcp`, `static`, `none`, and for 6 also `auto`; `manual` reads as `none`.
- `apply_ip(cfg, family, mode, cidr="", gw="") -> dict` - copy with the family's keys
set: `dhcp`/`auto` -> `ip[6]=dhcp|auto`, gateway removed; `static` -> `ip[6]=<cidr>`,
`gw[6]` set if given else removed; `none` -> both removed. Validation (ValueError):
static needs a CIDR of the right family with a `/prefix`
(`ipaddress.ip_interface`); a gateway must be an address of the same family; `auto`
only for family 6.
- `set_net(guest, changes: dict[str, str | None])` -> one call:
`set <kind> config <node> <vmid> [--<key> <value> ...] [--delete <k1,k2>] --wait`;
`None` values go to `--delete`. Empty `changes` -> ValueError.
### `app.py` (UI contract)
- Key `n` in InfoScreen opens `NetScreen` for the guest (needs the loaded config;
nothing if the guest has no `netN`).
- `NetScreen(ModalScreen)`: `Select#iface` (netN keys), `Select#bridge`
(`cli.bridges`), `Input#tag` (empty = none), `Checkbox#firewall`, `Select#ipv4`
(`dhcp`/`static`/`none`), `Input#ipv4_cidr`, `Input#ipv4_gw`, `Select#ipv6`
(`auto` labelled SLAAC / `dhcp` / `static` / `none`), `Input#ipv6_cidr`,
`Input#ipv6_gw`, buttons `#apply` / `#cancel`; escape = cancel. Prefilled from the
current values of the selected interface; changing `#iface` prefills again.
VM without cloud-init: IP widgets disabled and `Static#ip_note` explains why.
- Apply: build the new netN (and for VMs the new ipconfigN) with `apply_link` /
`apply_ip`; ValueError -> error notify, screen stays open, no CLI call. No change ->
info notify, nothing else. Otherwise `ConfirmScreen` listing each changed key with
its old and new value (raw strings); for a running guest it says link changes apply
immediately, and for VM ipconfig that it applies at next boot.
- On yes: guest busy (refuse if busy), worker runs `cli.set_net(guest, changes)`,
done/error notify, busy released, InfoScreen reloads. On success the old values
(`None` for keys that did not exist) are stored as the guest's undo entry.
- Live finding (CT 104): Proxmox applies a changed `ip` on a running CT, but static ->
dhcp left the CT without IPv4 (no DHCP client restart) until the CT was rebooted.
The confirm therefore says, for IP changes on a running container, that a reboot of
the container may be needed. Proxmox re-orders keys when it stores `netN`.
- Review fixes: undo entries are cleared on a context switch (VMIDs repeat across
clusters); `has_cloudinit` only looks at disk keys; CIDR needs a decimal prefix
1-32 / 1-128 (no netmask form, no /0); fields the user did not touch are not rewritten
(e.g. `ip6=manual` stays).
- Key `u` in InfoScreen: no undo entry -> info notify. Otherwise `ConfirmScreen`
showing what will be restored, then `cli.set_net(guest, old_values)` the same way;
the undo entry is cleared after success.
## Steps
- [x] Tests - red first.
- [x] Implementation - suite green, ruff clean, README keys `n` / `u`.
- [x] Live on CT 104 (approved), each step reverted: firewall off->on->off; VLAN
none->99->none (undo key); IPv6 SLAAC->DHCP->SLAAC; IPv4 DHCP->static
172.16.41.200/23 gw 172.16.40.1->DHCP. Bridge change cannot be tested live: the
node has a single bridge (`vmbr0`). Final net0 string identical to the baseline.
- [ ] Push `feat/network-edit`, merge to `main` (ask first).
---
# Snapshots in the info screen (issue #8)
## Decision (user-approved)
Info screen section "Snapshots"; keys `p` take, `R` roll back, `D` delete. LXC and VM.
CLI facts (cv4pve-cli 2.4.0, read-only probes):
- `get guest snapshots <node> <qemu|lxc> <vmid> -o json` -> list of dicts: `name`,
`description`, `snaptime` (epoch or null), `parent`, VM also `vmstate`. Always
contains a pseudo entry `name == "current"` ("You are here!") - not a snapshot.
- `create ct|vm snapshot <node> <vmid> <snapname> <descr>` (+ `--vmstate 1` for VM RAM),
no `--yes`; with `--wait`.
- `do rollback ct|vm <node> <vmid> <snapname>` needs `-y`; API param `start` (bool)
starts the guest after a successful rollback.
- `delete ct|vm snapshot <node> <vmid> <snapname>` needs `-y`.
DANGER: `delete ct|vm <node> <vmid> -y` (without `snapshot`) DESTROYS THE WHOLE GUEST.
### `cli.py`
- `valid_snapname(name) -> str` - ValueError unless it matches
`[A-Za-z][A-Za-z0-9_-]{0,39}` and is not `current` (case-insensitive).
- `snapshots(guest) -> list[dict]` - without `current`, newest first (`snaptime`
desc, null last).
- `take_snapshot(guest, name, description="", vmstate=False)` ->
`create <kind> snapshot <node> <vmid> <name> <description or "cv4pve-tui">
[--vmstate 1 (VM only)] --wait`; name validated first.
- `rollback_snapshot(guest, name, start=False)` ->
`do rollback <kind> <node> <vmid> <name> [--start 1] -y --wait`; name validated.
- `delete_snapshot(guest, name)` ->
`delete <kind> snapshot <node> <vmid> <name> -y --wait`; name validated first, and
the argv is asserted to contain `snapshot` right after the kind and the name right
after the vmid (defence against ever sending the guest-destroying form).
### `app.py` (UI contract)
- InfoScreen gets `Vertical#snapshots_section` (border_title "Snapshots") with
`DataTable#snapshots` (Name, Date `YYYY-MM-DD HH:MM` or `-`, Description, and for VMs
RAM `yes`/`no`) and `Static#snap_note`. Loaded with the rest; a failure of
`cli.snapshots` is shown in `#snap_note` ("unavailable: <summary>"), the rest of the
screen still works. Empty list -> `#snap_note` "No snapshots".
- `p`: `SnapshotScreen` dialog (DialogScreen style, title "Take snapshot: <name>
(<vmid>)"): `Input#snapname` prefilled `tui_YYYYMMDD_HHMM` (local time),
`Input#snapdesc`, `Checkbox#vmstate` (VMs only; not shown for CTs), buttons
`#yes`/`#no`, escape = no. Invalid name -> error notify, dialog stays, no call.
Yes -> busy guard, worker `cli.take_snapshot`, done/error notify, info reloads.
- `R`: roll back the snapshot under the cursor of `#snapshots`. `ConfirmScreen`
titled "Roll back" naming snapshot and date and saying everything changed since then
is lost; if the guest is running (live status) it says it will be started again and
`start=True` is passed. Yes -> busy guard, worker, notify, info reloads.
- `D`: delete the snapshot under the cursor. `ConfirmScreen` titled "Delete snapshot"
naming it. Yes -> busy guard, worker `cli.delete_snapshot`, notify, info reloads.
- `R`/`D` with no snapshot -> info notify, no dialog.
## Steps
- [x] Tests - red first. - [x] Implementation - suite green, ruff clean, README keys.
- Review fixes: rollback reads the live status right before rolling back (if it cannot
be read, no rollback); the info screen scrolls; a description starting with `-` is
refused (positional argument).
- [x] Live on CT 104: take `tui_live_test`, listed; roll back (CT running -> started
again); delete; list back to the baseline (no snapshots); CT running.
- [ ] PR with `Closes #8`, merge after user OK.
---
# Live usage: CPU %, memory used, disk used (issue #12)
## Decision (user-approved)
Usage in the main table and in the info screen's Resources section. Values refresh with
the listing (`g`, after actions); no auto-refresh and no rrddata trend (out of scope).
CLI facts (read-only probes):
- `get guests -o json` per guest: `cpu` (fraction of the guest's CPUs, 0..1; the web UI
shows `cpu*100` %), `mem`, `maxmem` (bytes), `disk`, `maxdisk` (bytes). Stopped guests
have 0s. VMs report `disk: 0` (no guest-agent disk info) - shown as `-`, not 0.
- `api get /nodes/<node>/<qemu|lxc>/<vmid>/status/current -o json` -> key/value list:
`status`, `cpu`, `cpus`, `mem`, `maxmem`, `disk`, `maxdisk`, `uptime`; CTs also
`swap`, `maxswap`; VMs also `freemem`, `balloon`.
### `cli.py`
- `current(guest) -> dict` - the status/current key/value list as a dict.
- `status(guest)` keeps its behaviour (returns `current(guest)["status"]`), argv
unchanged.
### `app.py`
- Main table: two new columns at the end, "CPU %" and "Mem %": `cpu*100` and
`mem/maxmem*100`, one decimal; `-` for a guest that is not running or lacks the field.
- Info screen Resources table gets a header and a third column: Resource | Configured |
In use. The load uses `cli.current(guest)` (one call) for both the live status and the
usage. In use, only when running:
- CPU: `<cpu*100:.1f> %`
- Memory: `<mem MiB> MiB (<pct:.0f> %)`
- Swap (CT): `<swap MiB> MiB`
- Disk rootfs (CT): `<disk GiB:.1f> GiB (<pct:.0f> %)`; other disks and all VM disks `-`
- otherwise `-`.
A failure of `cli.current` keeps today's behaviour (inline "unavailable", status
unknown -> not running).
---
# Task log (issue #10)
## Decision (user-approved)
`l` on the main screen: all recent cluster tasks. `l` on the info screen: the tasks of
that guest. Enter on a task shows its log.
CLI facts (read-only probes):
- `task list -o json` lacks start/end times. `api get /cluster/tasks -o json` has them:
list of dicts `upid`, `type` (e.g. `vzdump`, `qmstart`, `vzsnapshot`), `id` (VMID as a
string, `""` for node tasks), `node`, `user`, `starttime`, `endtime` (epoch; missing
or null while running), `status` (`OK`, an error text, or missing while running),
`saved`, `tokenid`.
- `task log <upid>` prints the log as plain text lines (e.g. `TASK OK` at the end).
### `cli.py`
- `tasks() -> list[dict]` - `/cluster/tasks`, newest first by `starttime`.
- `task_log(upid) -> str` - `task log <upid>`; ValueError unless `upid` starts with
`UPID:` (positional argument).
### `app.py` (UI contract)
- `TasksScreen(ModalScreen)`, opened by `l` on the main screen (all tasks) and on the
info screen (`vmid` filter: only tasks whose `id == str(vmid)`). Frame `#tasks_section`
titled "Tasks" or "Tasks: <name> (<vmid>)". `DataTable#tasks` (row cursor): Start
(`YYYY-MM-DD HH:MM` local), Duration (`1h 2m`, `3m 12s`, `45s`; `running` without
endtime), Node, Type, VMID (`-` for node tasks), User, Status (`OK`, the error text, or
`running`). A failed task (status present and not `OK`) gets a `!` in a leading
column. `Static#tasks_note` says "No tasks" when empty. Loaded in a thread worker;
failure -> error notify, screen stays. Keys: `enter` open the log, `g` reload,
`escape` close.
- `TaskLogScreen` (DialogScreen style, wide box), title "Log: <type> <vmid or node>",
a read-only plain-text log widget `#tasklog` (no markup parsing), loaded in a worker;
failure -> error notify. `escape` closes.
- Main-screen footer shows `l Tasks`.
---
# Node package updates (issue #13)
## Decision (user-approved)
`U` on the main screen shows pending package updates per node; the frame's bottom
border shows the count. Read-only: running `apt update`/`upgrade` stays out of scope.
CLI facts (read-only probes):
- `get nodes -o json` -> list of dicts with `node`, `status` (`online`/`offline`), ...
- `get node packages <node> -o json` -> list of dicts with `Package`, `OldVersion`,
`Version`, `Title`, `Priority`, `Origin`, ... (23 on mainframe). The list is as fresh
as the node's last `apt update` (Proxmox runs it daily); the call does not refresh it.
### `cli.py`
- `nodes() -> list[str]` - names of online nodes, sorted.
- `package_updates(node) -> list[dict]` - `get node packages <node> -o json`, sorted by
`Package`.
### `app.py` (UI contract)
- `UpdatesScreen(ModalScreen)` opened by `U`: frame `#updates_section` titled "Package
updates", `DataTable#updates`: Node, Package, Current (`OldVersion`), New
(`Version`), Origin, Priority; `Static#updates_note`: "No pending updates" when
empty, plus one line per node that failed ("<node>: unavailable: <summary>") and a
fixed hint that the list is as of the node's last apt update. Loaded in a thread
worker; `g` reloads; `escape` closes. A failing node does not hide the others.
- The main frame's bottom border adds "· N package updates" when N > 0, after the
backup count. The count is fetched in a worker on mount, after a context switch, and
every time the UpdatesScreen loads - not on every guest reload (one CLI call per node).
It follows the same generation guard as the guest list (a count from the old cluster
is dropped after a switch) and is cleared when a switch starts. Failures leave the
count unknown (nothing shown), no error notify on mount.
- Main footer shows `U Updates`.
---
# Search and filter in the guest table (issue #16)
## Decision
- `/` on the main screen shows `Input#filter` (hidden until then) below the guest table
and focuses it. The table filters as you type.
- Filter text = space-separated terms, all must match (AND), case-insensitive:
- a plain term matches a substring of the name, the VMID or one of the tags
(`tags` from `get guests`, `;`-separated);
- `is:running` / `is:stopped` match the status;
- `type:lxc` / `type:ct` and `type:vm` / `type:qemu` match the guest type.
- Enter in the filter keeps the filter and moves focus to the table. Escape in the
filter clears it, hides the input and moves focus to the table.
- The frame's bottom border gets "· filter: <text> · N of M" while a filter is active,
after the context name and before the backup/package counts.
- Actions (s/S/x/r/t/Enter) act on the selected *visible* row only; the table only ever
contains the matching guests. A reload (g, after actions, context switch) keeps the
filter applied. A context switch keeps the filter text.
- Placeholder of the input shows the syntax: `name, vmid, tag · is:running · type:lxc`.
- No match: empty table, and the frame says "0 of M"; action keys do nothing.
### Code
- Pure function `guest_matches(guest: dict, query: str) -> bool` in `app.py` (or
`cli.py`-free module-level helper) so it can be tested without the UI.
---
# Autostart, startup order and tags (issue #18)
## Decision
Shown on the info screen; edited with key `o` (options) there. LXC and VM.
CLI facts (read-only probes, PVE 9.2):
- Config keys: `onboot` (0/1, absent = 0), `startup` (`[[order=]N][,up=S][,down=S]`,
absent = none), `tags` (`;`-separated, e.g. `172.16.41.200;community-script;os`).
- Write with `set <kind> config <node> <vmid> --onboot 0|1 --startup <s> --tags <t>
[--delete startup,tags] --wait` - i.e. the existing `cli.set_net(guest, changes)` (one
call; `None` values go to `--delete`).
- DANGER: `delete ct|vm ... tags` exists but shares the guest-destroying `delete ct|vm
<node> <vmid>` form. Never use it; removing tags = `--delete tags` on `set config`.
### `cli.py`
- `startup_value(order, up, down) -> str | None` - ints >= 0 or None; builds
`order=N[,up=S][,down=S]` (only the given parts); all None -> None. ValueError for
negatives/non-ints. `parse_startup(s) -> (order, up, down)` (accepts a bare leading
number as order).
- `tags_value(text) -> str | None` - split on `;`, `,` or whitespace, drop empties and
duplicates (order kept); each tag must match `[A-Za-z0-9_][A-Za-z0-9_+.-]*` else
ValueError; joined with `;`; none -> None.
### `app.py` (UI contract)
- Info screen Resources table gets rows "Autostart" (`yes`/`no`), "Startup" (e.g.
`order 2, up 10 s, down 30 s` or `-`), "Tags" (`a, b, c` or `-`); In use `-`.
- `o`: `BootScreen` dialog (DialogScreen style, title "Boot and tags: <name> (<vmid>)"):
`Checkbox#onboot`, `Input#order`, `Input#up`, `Input#down` (empty = not set),
`Input#tags` (prefilled `a; b; c`), buttons `#apply`/`#cancel`, escape = cancel.
Apply: build changes for keys whose value differs (`onboot` "1"/"0", `startup` string
or None, `tags` string or None); invalid -> error notify, dialog stays, no call; no
change -> info notify; else `ConfirmScreen` titled "Boot and tags" listing old -> new
per key; yes -> busy guard, `cli.set_net(guest, changes)` via start_job, info reloads.
---
# fstrim in a VM via the guest agent (issue #19)
## Decision
Key `f` on the info screen, VMs only. Not run live (the issue asks for approval first);
tested with mocks.
CLI facts:
- `do vm agent ping <node> <vmid> -o json` -> `[{"key": "result", "value": "{}"}]` when the
agent answers (verified read-only on VM 108); non-zero exit when it does not.
- `do vm agent fstrim <node> <vmid> -o json` -> the same key/value shape; QEMU guest
agent's result is `{"paths": [{"path": "/", "trimmed": <bytes>, "minimum": 0}, ...]}`
(a path may carry `"error"` instead of `trimmed`). Not verified live.
- VM config `agent` = `enabled=1` (or `1`) when the agent is enabled.
### `cli.py`
- `agent_enabled(config) -> bool` - `agent` is `1` or starts with `1` / has `enabled=1`.
- `agent_ping(guest)` -> `do vm agent ping <node> <vmid> -o json`; CliError if the agent
does not answer.
- `fstrim(guest) -> list[dict]` -> `do vm agent fstrim <node> <vmid> -o json`; returns
the `paths` list parsed from the `result` value (`[]` if missing or unparseable).
### `app.py` (UI contract)
- `f` on the info screen:
- container -> info notify that containers have no guest agent (`pct fstrim` on the
node instead); nothing runs;
- VM not running, or `agent_enabled` false -> warning notify saying why; nothing runs;
- otherwise `ConfirmScreen` titled "fstrim" naming the VM and saying it frees unused
blocks on thin-provisioned storage and adds I/O load while it runs;
- yes -> busy guard, worker: `cli.agent_ping` then `cli.fstrim`; done notify with the
trimmed total in GiB/MiB and the number of paths (errors per path mentioned);
CliError -> error notify (ping failure says the agent did not answer).
---
# Restore a guest from a backup (issue #15)
## Decision
From the info screen's backup list, key `r` ("Restore backup"; `R` stays snapshot
rollback). Two targets: a new VMID (default, original untouched) or overwriting the
guest itself (destructive). Not run live: a live test would create a guest that then
needs deleting, which the issue reserves for the user's approval. Tested with mocks.
CLI facts (cv4pve-cli 2.4.0, read-only probes):
- `do restore ct|vm <node> <vmid> <volume> [--key value] -y --wait` - "creates/overwrites
the target vmid". API params: `storage` (target storage for the disks), `force`
(bool; required to overwrite an existing guest), `start` (bool), `unique` (VM: new
MACs).
- `get cluster nextid -o json` -> a JSON number, e.g. `105`.
- Backup entries carry `volid` (`truenas-backup:backup/vzdump-lxc-104-...tar.zst`),
`subtype` (`lxc`|`qemu`), `vmid`.
- Node storages: `content` lists `rootdir` (CT disks) and/or `images` (VM disks).
### `cli.py`
- `next_id() -> int`.
- `valid_volid(volid) -> str` - ValueError unless it matches
`^[A-Za-z0-9][A-Za-z0-9_.-]*:backup/vzdump-(lxc|qemu)-\d+-\S+$` (positional argument).
- `restore_storages(guest) -> list[str]` - enabled+active storages on the guest's node
whose `content` has `rootdir` (CT) or `images` (VM), API order.
- `restore(guest, volid, vmid, storage, force=False, start=False)` ->
`do restore <kind> <node> <vmid> <volid> --storage <storage> [--force 1] [--start 1]
-y --wait`; kind from the backup's guest kind (the guest dict's `type`); `vmid` a
positive int; volid validated; `force=True` only allowed when `vmid ==
guest["vmid"]` (ValueError otherwise - never overwrite some other guest).
### `app.py` (UI contract)
- `r` on the info screen: the backup under the cursor of `#backups` (the visible rows;
hidden earlier-guest backups are not restorable unless shown with `a`). No backup ->
info notify.
- `RestoreScreen` (DialogScreen, title "Restore <date> backup of <name> (<vmid>)"):
`RadioSet#target` with `RadioButton#new` (default, "New guest") and
`RadioButton#overwrite` ("Overwrite <name> (<vmid>)"); `Input#newid` prefilled with
`cli.next_id()` (used for "new"); `Select#storage` (`restore_storages`, preselected
to the storage of the guest's rootfs/first disk if listed); `Checkbox#start` (off);
`Input#confirm` (typed VMID, only needed for overwrite); `#apply`/`#cancel`; escape =
cancel. A note says a new copy keeps the original's network settings (same static
IP), so leave Start off until it is changed.
- Apply, new: `newid` must be a positive int not used by any guest in the current
listing (app.guests) -> else error notify, no call. Then `ConfirmScreen` titled
"Restore" summarising backup, target VMID, storage, start; yes -> busy guard on the
source guest, worker `cli.restore(guest, volid, newid, storage, force=False,
start=...)`, notify, main list reloads.
- Apply, overwrite: the guest must be stopped (live `cli.status` at apply time) -> else
error notify; `Input#confirm` must equal the VMID -> else error notify; then
`ConfirmScreen` titled "Overwrite" saying the guest's current disks and config are
replaced and cannot be undone; yes -> `cli.restore(guest, volid, guest vmid, storage,
force=True, start=...)`.
- All CliErrors -> error notify; busy released; markup-safe texts.
+121
View File
@@ -0,0 +1,121 @@
# Shrink a container's root disk
## Problem
Proxmox only grows disks (`resize_vm` dies with "unable to shrink disk size"). A
container whose rootfs was sized too large cannot be made smaller from the web UI or
this TUI. Two routes exist, verified against pve-container 6.1.18 (master) source:
1. API: restore a backup with an explicit `rootfs=<storage>:<GiB>` ("advanced restore
mode", pct.adoc). Reachable through cv4pve-cli (`do restore ct ... --rootfs S:N`
forwards the key; checked with `--dry-run`).
2. Node shell: copy the files into a new, smaller volume and swap it in.
Both are dangerous; the TUI must say so plainly.
## Approach
### A. TUI: smaller rootfs on restore (container backups only)
- `RestoreScreen` gets an optional field "Root disk GiB" for lxc backups. Empty =
today's behaviour, unchanged.
- With a size set, only **New guest** is allowed. Overwrite + size is refused: with
`force=1` PVE destroys every old volume before it extracts, and a failed extraction
then deletes the new disks and the config too; the container is gone.
- `cli.restore()` gets `rootfs_gb: int | None`; when set it checks it with
`_positive` (a size of 0 makes PVE allocate an *unlimited* subvol on ZFS/dir) and
adds `--rootfs <storage>:<N>` (same storage as the dialog's choice). Never
`ignore-unpack-errors`.
- The confirm screen for a shrink restore is a separate, red warning:
- every mpX of the backup is dropped from the new config; files of `backup=1`
mount points land inside the root disk and count against its size; `backup=0`
mount points are not in the backup at all;
- raw `lxc.*` lines (including `lxc.mount.entry` bind mounts) may come back when
cv4pve-cli runs as root@pam: do not run both guests at once;
- rootfs options (acl, quota, mountoptions, replicate) are not carried over;
- names the guest's *current* mount points, saying the backup may differ;
- shows rootfs used space when the guest is running (`status/current` `disk`;
0 when stopped, shown as "unknown"), and says this is not a fit check: the
backup may be older, mount point data and ext4 overhead (5 % reserve, inodes)
come on top;
- a too-small disk makes the restore task fail; the original is untouched because
this is always a new VMID.
- Hard refusal only where it is certain: size not a positive integer, or size <=
used rootfs GiB of a running guest.
- Known limit, unchanged from today's restore: `run(timeout=3600)` can give up while
the PVE task still runs.
### B. Node script `scripts/pct-shrink-rootfs` (bash, run as root on the node)
Usage: `pct-shrink-rootfs [--yes] <vmid> <new-size-GiB>`. Without `--yes` it only
measures and prints the plan; measuring runs `pct mount`/`pct unmount`, which sets
and clears `lock: mounted` (so even a dry run touches the config briefly).
Refuses (exit non-zero, nothing changed) when: not root; `pct` missing; CT not
stopped; config has any `[...]` section (snapshots, `[pve:pending]`); `lock:` set;
`template: 1` or `protection: 1`; HA-managed (`ha-manager status`); a replication
job exists (`pvesr list`); rootfs is not a volume; **any `mpN` or `lxc.mount.entry`**
(rootfs-only CTs: no shadowed files, `df` measures rootfs alone, `pct mount` cannot
fail on another storage); new size >= current size; size not a positive integer.
Fit check after `pct mount` (`df -B1` and `df -i` on the rootfs): used bytes * 1.1 <=
N GiB * 0.93 (5 % root reserve + ~2 % ext4 metadata), and used inodes * 1.1 <= the
new disk's inodes (N * 65536, mkfs.ext4 default ratio). On any error after
`pct mount` the trap runs `pct unmount`; if that fails it stops and prints the manual
cleanup (`pct unmount`, `pct unlock`), touching nothing else.
Steps (copy route, any storage):
1. `pct set <id> --mp9 <storage>:<N>,mp=/.shrink-new` - PVE allocates and formats;
the script reads back mp9's volid and `size=`.
2. `pct mount <id>`; `rsync -aHAX --numeric-ids --whole-file --sparse
--one-file-system --exclude=/.shrink-new` old rootfs -> new; `pct unmount <id>`.
On rsync failure: unmount, then free the new volume explicitly - `pct set
--delete mp9` only moves it to `unusedN`, so the script then deletes the
`unusedK` whose value is exactly the new volid (found by value, never by index).
3. Swap, under PVE's config flock (`flock /run/lock/lxc/pve-config-<id>.lock`):
copy the config to `/root/pct-shrink-<id>-<timestamp>.conf`; rewrite the main
section with awk (exact string compare, no regex on volids) into
`<conf>.tmp.<pid>` and rename it, as PVE's `file_set_contents` does on pmxcfs;
refuse if rootfs or mp9 changed since the copy: `rootfs:` gets the
new volid with the old options and mp9's `size=`, the `mp9:` line goes, and
`unusedK: <old volid>` is added at the first free K. No `pct rescan`: the script
knows both volids and the size, so rescan's side effects (every orphan of the
VMID added as unused, `size=0` on pve-container < 6.1.9) are avoided.
4. Print the exact `unusedK` key and value of the old rootfs: start the CT, check
it, then `pct set <id> --delete unusedK`. The script never deletes the old volume.
The in-place ZFS `refquota` shortcut is left out: one route for every storage is
less code to get wrong, and the copy keeps the original until the user deletes it.
chattr flags (`+i`, `+a`) are not copied by rsync; the script says so.
Tests: pytest runs the script with stub `pct`, `df`, `rsync`, `flock`,
`ha-manager`, `pvesr` on `PATH` and a temp config dir (`PCT_CONF_DIR` override,
default `/etc/pve/lxc`; the plan output prints the directory used). bats is not
installed and CI only runs pytest. ShellCheck via the edit hook.
## Out of scope
- Shrinking mpX volumes, and containers that have any mount point (script).
- Overwrite-in-place restore with a smaller disk.
- Restoring back to the original VMID (second backup/restore cycle).
- Running the node script from the TUI (it needs a root shell on the node).
- Moving `backup=0` mount points to the new guest.
## Steps
- [x] `cli.restore(rootfs_gb=...)` adds `--rootfs S:N`; tests in `test_cli.py`.
- [x] RestoreScreen: size field for lxc, refuse with overwrite, refuse <= used of a
running guest; shrink confirm text with the warnings above; tests in
`test_app.py`.
- [x] `scripts/pct-shrink-rootfs` + `tests/test_pct_shrink.py`: each refusal, dry
run changes no config, happy path (exact new config, `unusedK` at first free
K with existing `unused0`), dir-style volid with `/` and `.`, rsync failure
frees the new volume by value, unmount failure stops.
- [x] README: key table row and a warning paragraph; script usage.
- [ ] Full `uv run pytest`, ruff, shellcheck green.
## Open questions
- Should a shrink restore pass `--unique 1` (new MACs)? Today's restore does not, it
warns instead. Proposal: keep that, the warning already covers it.
+6 -1
View File
@@ -10,7 +10,12 @@ dependencies = ["textual>=8.2"]
cv4pve-tui = "cv4pve_tui.app:main"
[dependency-groups]
dev = ["pytest>=8", "pytest-asyncio>=1.0"]
dev = [
"hypothesis>=6.170.14",
"pytest>=8",
"pytest-asyncio>=1.0",
"ruff==0.17.3",
]
[build-system]
requires = ["hatchling"]
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env bash
# Shrink a container's root disk by copying it into a smaller volume. Run as root
# on the Proxmox node. Without --yes it only measures and prints the plan.
# Spec: docs/plans/lxc-shrink.md (section B).
set -euo pipefail
CONF_DIR=${PCT_CONF_DIR:-/etc/pve/lxc}
BACKUP_DIR=${PCT_SHRINK_BACKUP_DIR:-/root}
GIB=1073741824
ctid="" conf="" mounted=0 new_volid=""
die() { echo "pct-shrink-rootfs: $*" >&2; exit 1; }
usage() { die "usage: pct-shrink-rootfs [--yes] <vmid> <new-size-GiB>"; }
conf_get() { awk -v k="$1" 'index($0, k ": ") == 1 { print substr($0, length(k) + 3); exit }' "$conf"; }
refuse_if() { if grep -Eq "$1" "$conf"; then die "$2"; fi; }
# The value of the unusedK line that holds exactly this volid (no regex on the volid).
unused_key_of() {
awk -v v="$1" '/^unused[0-9]+: / { if (substr($0, index($0, ": ") + 2) == v) { print substr($0, 1, index($0, ":") - 1); exit } }' "$conf"
}
size_bytes() {
[[ $1 =~ ^([0-9]+)([KMGT]?)$ ]] || die "cannot read the current rootfs size '$1'"
local m=1
case ${BASH_REMATCH[2]} in K) m=1024 ;; M) m=$((1024 ** 2)) ;; G) m=$GIB ;; T) m=$((1024 ** 4)) ;; *) ;; esac
echo $((BASH_REMATCH[1] * m))
}
manual_cleanup() {
cat >&2 <<EOT
pct-shrink-rootfs: 'pct unmount $ctid' failed. Nothing else was touched. Clean up by hand:
pct unmount $ctid
pct unlock $ctid # if the lock remains
EOT
}
unmount_or_die() {
mounted=0
pct unmount "$ctid" || { manual_cleanup; exit 1; }
}
on_exit() {
local rc=$?
trap - EXIT
if ((mounted)); then unmount_or_die; fi
if ((rc != 0)) && [[ -n $new_volid ]]; then
echo "pct-shrink-rootfs: the new volume $new_volid is still in the config as mp9;" \
"remove it with 'pct set $ctid --delete mp9' and then the unusedK that holds it." >&2
fi
exit "$rc"
}
# Runs inside flock. Writes like PVE's file_set_contents: "<file>.tmp.<pid>" in the same
# pmxcfs directory, then rename. Re-checks both volids under the lock before writing.
swap_config() {
local conf=$1 old=$2 new=$3 size=$4 backup=$5 tmp=$1.tmp.$$
cp "$conf" "$backup"
if ! awk -v old="$old" -v new="$new" -v size="$size" '
{ key = substr($0, 1, index($0, ":") - 1); val = substr($0, index($0, ": ") + 2) }
key ~ /^unused[0-9]+$/ { used[substr(key, 7) + 0] = 1 }
key == "mp9" { split(val, m, ","); ok_new = (m[1] == new); next }
key == "rootfs" {
n = split(val, a, ",")
ok_old = (a[1] == old)
out = new; seen = 0
for (i = 2; i <= n; i++) {
if (substr(a[i], 1, 5) == "size=") { out = out ",size=" size; seen = 1 }
else out = out "," a[i]
}
if (!seen) out = out ",size=" size
print "rootfs: " out; next
}
{ print }
END { if (!ok_old || !ok_new) exit 3; k = 0; while (k in used) k++; print "unused" k ": " old }
' "$conf" > "$tmp"; then
rm -f "$tmp"
echo "pct-shrink-rootfs: config changed during the copy (rootfs or mp9 differs); not swapped" >&2
return 1
fi
mv -f "$tmp" "$conf"
}
export -f swap_config
free_new_volume() {
local key vol=$new_volid
pct set "$ctid" --delete mp9
new_volid="" # no longer mp9: the on_exit hint would be wrong
key=$(unused_key_of "$vol")
[[ -n $key ]] || die "cannot find $vol among the unused volumes; delete it by hand"
pct set "$ctid" --delete "$key"
}
main() {
local yes=0 size root rootfs old_volid storage cur_bytes used iused out mp9 new_size backup key
while [[ ${1:-} == --* ]]; do
[[ $1 == --yes ]] || usage
yes=1
shift
done
[[ $# -eq 2 ]] || usage
ctid=$1 size=$2
[[ $ctid =~ ^[0-9]+$ ]] || die "vmid must be a number"
[[ $size =~ ^[1-9][0-9]{0,8}$ ]] || die "new size must be a positive integer (GiB)"
[[ $(id -u) -eq 0 ]] || die "must run as root on the node"
command -v pct > /dev/null || die "pct not found; run this on a Proxmox node"
conf=$CONF_DIR/$ctid.conf
[[ -f $conf ]] || die "no config $conf"
[[ $(pct status "$ctid") == "status: stopped" ]] || die "container $ctid must be stopped"
refuse_if '^\[' "config has a snapshot or pending section"
refuse_if '^lock:' "container is locked"
refuse_if '^template: 1$' "container is a template"
refuse_if '^protection: 1$' "container has protection set"
refuse_if '^(mp[0-9]+|lxc\.mount\.entry):' "container has a mount point (mpN or lxc.mount.entry); not supported"
out=$(ha-manager status)
if grep -Eq "(^| )ct:$ctid( |\$)" <<< "$out"; then die "container is HA-managed"; fi
out=$(pvesr list)
if grep -Eq "^$ctid-" <<< "$out"; then die "container has a replication job"; fi
rootfs=$(conf_get rootfs)
old_volid=${rootfs%%,*}
[[ $old_volid == *:* && $old_volid != /* ]] || die "rootfs is not a volume: $old_volid"
storage=${old_volid%%:*}
[[ $rootfs =~ (^|,)size=([^,]+) ]] || die "rootfs has no size= option"
cur_bytes=$(size_bytes "${BASH_REMATCH[2]}")
((size * GIB < cur_bytes)) || die "new size must be smaller than the current rootfs (${BASH_REMATCH[2]})"
trap on_exit EXIT
root=/var/lib/lxc/$ctid/rootfs
mounted=1
pct mount "$ctid"
used=$(df -B1 --output=used "$root" | tail -n1 | tr -d ' ')
iused=$(df --output=iused "$root" | tail -n1 | tr -d ' ')
unmount_or_die
[[ $used =~ ^[0-9]+$ && $iused =~ ^[0-9]+$ ]] || die "cannot read df output"
echo "Container $ctid, rootfs $old_volid ($storage): $used bytes, $iused inodes used."
echo "Config dir: $CONF_DIR; backup dir: $BACKUP_DIR"
# 5 % root reserve + ~2 % ext4 metadata; mkfs.ext4 default is one inode per 16 KiB.
((used * 1100 <= size * GIB * 930)) || die "data too full for ${size}G: need used * 1.1 <= 93 % of the disk"
((iused * 11 <= size * 65536 * 10)) || die "too many inodes for ${size}G: $iused used, ${size}G gives $((size * 65536))"
if ((!yes)); then
echo "Plan: allocate ${size}G on $storage, rsync the root disk into it, swap it into rootfs."
echo "The old volume is kept as unusedK. Re-run with --yes to do it."
return 0
fi
pct set "$ctid" --mp9 "$storage:$size,mp=/.shrink-new"
mp9=$(conf_get mp9)
[[ -n $mp9 ]] || die "mp9 not found after pct set"
new_volid=${mp9%%,*}
[[ ,$mp9, =~ ,size=([^,]+), ]] || die "mp9 has no size= option: $mp9"
new_size=${BASH_REMATCH[1]}
mounted=1
pct mount "$ctid"
if ! rsync -aHAX --numeric-ids --whole-file --sparse --one-file-system \
--exclude=/.shrink-new "$root/" "$root/.shrink-new/"; then
unmount_or_die
free_new_volume
die "rsync failed; the new volume was removed and the config is unchanged"
fi
unmount_or_die
backup=$BACKUP_DIR/pct-shrink-$ctid-$(date +%Y%m%d-%H%M%S).conf
flock "/run/lock/lxc/pve-config-$ctid.lock" \
bash -c 'set -euo pipefail; swap_config "$@"' _ "$conf" "$old_volid" "$new_volid" "$new_size" "$backup"
new_volid=""
key=$(unused_key_of "$old_volid")
cat <<EOT
Done. rootfs is now on the new ${new_size} volume; config backup: $backup
The old root disk is kept as '$key: $old_volid'.
Start the container and check it. Then free the old volume:
pct set $ctid --delete $key
rsync does not copy chattr flags (+i, +a); re-apply them in the container if you used any.
EOT
}
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
main "$@"
fi
+1562 -102
View File
File diff suppressed because it is too large. Load diff
+374 -5
View File
@@ -1,9 +1,14 @@
"""Thin subprocess wrapper around cv4pve-cli."""
import ipaddress
import json
import re
import subprocess
# Output formats this app parses (config list text, prune-backups as a JSON string, the CT
# ip-addresses JSON string, exit codes with --wait) were verified against this version.
TESTED_VERSION = "2.4.0"
class CliError(Exception):
"""cv4pve-cli failed; str() is its combined, stripped output."""
@@ -23,7 +28,7 @@ class CliError(Exception):
return str(self)
def run(*args: str) -> str:
def run(*args: str, timeout: int = 330) -> str:
try:
proc = subprocess.run(
["cv4pve-cli", *args],
@@ -31,7 +36,7 @@ def run(*args: str) -> str:
capture_output=True,
text=True,
stdin=subprocess.DEVNULL,
timeout=330,
timeout=timeout,
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise CliError(str(exc)) from exc
@@ -132,12 +137,17 @@ def pending_keys(guest: dict) -> list[str]:
return [i["key"] for i in items if i.get("pending") is not None]
def status(guest: dict) -> str:
"""Live status; get guests (/cluster/resources) lags a few seconds behind actions."""
def current(guest: dict) -> dict:
"""status/current as a dict: live status plus cpu, mem, maxmem, disk, ..."""
path = f"/nodes/{guest['node']}/{_api_type(guest)}/{guest['vmid']}/status/current"
return {
i["key"]: i["value"] for i in json.loads(run("api", "get", path, "-o", "json"))
}["status"]
}
def status(guest: dict) -> str:
"""Live status; get guests (/cluster/resources) lags a few seconds behind actions."""
return current(guest)["status"]
_CONTEXT_LINE = re.compile(r"^(\*)?\s*(\S+)\s+(\S+)\s+\((.*)\)\s*$")
@@ -170,6 +180,78 @@ def backup_storages(guest: dict) -> list[str]:
]
def next_id() -> int:
return int(json.loads(run("get", "cluster", "nextid", "-o", "json")))
_STORAGE = r"[A-Za-z0-9][A-Za-z0-9_.-]*"
_VOLID = re.compile(rf"{_STORAGE}:backup/vzdump-(lxc|qemu)-[0-9]+-\S+")
def valid_volid(volid: str) -> str:
"""The volid is a positional argument: refuse anything that is not a vzdump volume."""
if not _VOLID.fullmatch(volid):
raise ValueError(f"not a backup volume id: {volid!r}")
return volid
def restore_storages(guest: dict) -> list[str]:
"""Enabled, active storages on the guest's node that hold its disks, in API order."""
content = "images" if guest["type"] == "qemu" else "rootdir"
items = json.loads(run("get", "node", "storages", guest["node"], "-o", "json"))
return [
s["storage"]
for s in items
if content in (s.get("content") or "").split(",")
and s.get("enabled")
and s.get("active")
]
def restore(
guest: dict,
volid: str,
vmid: int,
storage: str,
force: bool = False,
start: bool = False,
rootfs_gb: int | None = None,
) -> None:
"""Restore `volid` as `vmid`; `force` (overwrite) only for the guest's own VMID.
`rootfs_gb` (container, new guest only) restores onto a root disk of that size.
"""
_positive(vmid, "vmid")
if force and vmid != guest["vmid"]:
raise ValueError(
"refusing to overwrite a guest other than the one restored from"
)
valid_volid(volid)
backup_type = re.search(r":backup/vzdump-(lxc|qemu)-", volid).group(1)
if backup_type != _api_type(guest): # a reused VMID can carry the other guest type
raise ValueError(
f"a {backup_type} backup cannot be restored as this {kind(guest)}"
)
if not re.fullmatch(_STORAGE, storage):
raise ValueError(f"invalid storage name: {storage!r}")
if rootfs_gb is not None:
# 0 would make PVE allocate an unlimited subvol on ZFS
_positive(rootfs_gb, "root disk size")
if force or backup_type != "lxc":
raise ValueError("a smaller root disk needs a container and a new guest")
opts = ["--storage", storage]
if force:
opts += ["--force", "1"]
if start:
opts += ["--start", "1"]
if rootfs_gb is not None:
opts += ["--rootfs", f"{storage}:{rootfs_gb}"]
run(
"do", "restore", kind(guest), guest["node"], str(vmid), volid, *opts,
"-y", "--wait", timeout=3600,
) # fmt: skip
def backup_jobs() -> list[dict]:
jobs = json.loads(run("get", "cluster", "backup-jobs", "-o", "json"))
for job in jobs: # the CLI returns prune-backups as a JSON string
@@ -226,6 +308,58 @@ def backup_now(guest: dict, storage: str) -> None:
) # fmt: skip
def agent_enabled(config: dict) -> bool:
"""VM option `agent`: `1`, `1,<opt>=...` or `enabled=1,...`."""
parts = str(config.get("agent") or "").split(",")
return parts[0] == "1" or "enabled=1" in parts
def agent_ping(guest: dict) -> None:
"""CliError if the guest agent does not answer."""
run("do", "vm", "agent", "ping", guest["node"], str(guest["vmid"]), "-o", "json")
def fstrim(guest: dict) -> list[dict]:
"""Trim the guest's filesystems; the per-path results, `[]` if they cannot be read.
Well-formed entries come back as the agent sent them. Odd ones are repaired, not
fatal - the trim has already happened when the output is parsed: non-dict entries
are dropped, a non-int `trimmed` becomes 0 and a missing `path` becomes "?".
"""
args = (
"do",
"vm",
"agent",
"fstrim",
guest["node"],
str(guest["vmid"]),
"-o",
"json",
)
out = run(
*args, timeout=1800
) # a large disk can take longer than the default 330 s
try:
items = json.loads(out)
value = {x["key"]: x["value"] for x in items}.get("result") or "{}"
result = value if isinstance(value, dict) else json.loads(value)
paths = result.get("paths") if isinstance(result, dict) else None
except (ValueError, TypeError, KeyError, AttributeError):
return []
found = []
for p in paths if isinstance(paths, list) else []:
if not isinstance(p, dict):
continue
p = dict(p)
if "trimmed" in p and (
not isinstance(p["trimmed"], int) or isinstance(p["trimmed"], bool)
):
p["trimmed"] = 0
p.setdefault("path", "?")
found.append(p)
return found
def parse_net(value: str) -> dict:
"""`k=v,k=v` to an ordered dict; a VM's leading `<model>=<MAC>` is just the first pair."""
return dict(item.partition("=")[::2] for item in value.split(",") if item)
@@ -274,3 +408,238 @@ def interfaces(guest: dict) -> list[dict]:
)
found.append({"name": i["name"], "mac": i.get("hardware-address"), **addrs})
return [i for i in found if i["name"] != "lo"]
def bridges(guest: dict) -> list[str]:
items = json.loads(run("get", "node", "network", guest["node"], "-o", "json"))
return sorted(i["iface"] for i in items if i.get("type") in ("bridge", "OVSBridge"))
def format_net(d: dict) -> str:
return ",".join(
f"{k}={v}" if v else k for k, v in d.items()
) # bare flags stay bare
def has_cloudinit(config: dict) -> bool:
"""A cloud-init drive: a disk (not a name or description) whose volume says so."""
return any(
_VM_DISK.fullmatch(k) and "cloudinit" in str(v) for k, v in config.items()
)
def apply_link(net: dict, bridge: str, tag: int | None, firewall: bool) -> dict:
"""Copy of `net` with bridge, VLAN tag and firewall set; other keys stay in place."""
if tag is not None and not 1 <= tag <= 4094:
raise ValueError("VLAN tag must be 1-4094")
out = dict(net)
out["bridge"] = bridge
if tag is None:
out.pop("tag", None)
else:
out["tag"] = str(tag)
if firewall:
out["firewall"] = "1"
else:
out.pop("firewall", None)
return out
def ip_mode(cfg: dict, family: int) -> tuple[str, str, str]:
"""`(mode, cidr, gw)` of one family from an LXC net or VM ipconfig dict."""
ip, gw = cfg.get(_IP_KEYS[family][0], ""), cfg.get(_IP_KEYS[family][1], "")
if ip in ("dhcp", "auto"):
return ip, "", ""
if ip in ("", "manual"):
return "none", "", ""
return "static", ip, gw
_IP_KEYS = {4: ("ip", "gw"), 6: ("ip6", "gw6")}
def apply_ip(cfg: dict, family: int, mode: str, cidr: str = "", gw: str = "") -> dict:
"""Copy of `cfg` with one family's address keys set; ValueError on bad input."""
ip_key, gw_key = _IP_KEYS[family]
out = dict(cfg)
if mode == "static":
try:
prefix = cidr.partition("/")[2]
if not (
prefix.isdecimal() and 1 <= int(prefix) <= (32 if family == 4 else 128)
):
raise ValueError # CIDR only: no netmask form, no /0
if ipaddress.ip_interface(cidr).version != family:
raise ValueError
except ValueError:
raise ValueError(f"IPv{family} needs an address like 10.0.0.5/24") from None
try:
if gw and ipaddress.ip_address(gw).version != family:
raise ValueError
except ValueError:
raise ValueError(
f"IPv{family} gateway must be an IPv{family} address"
) from None
out[ip_key] = cidr
if gw:
out[gw_key] = gw
else:
out.pop(gw_key, None)
elif mode in ("dhcp", "auto"):
if mode == "auto" and family != 6:
raise ValueError("SLAAC (auto) exists only for IPv6")
out[ip_key] = mode
out.pop(gw_key, None)
elif mode == "none":
out.pop(ip_key, None)
out.pop(gw_key, None)
else:
raise ValueError(f"unknown mode: {mode}")
return out
def set_net(guest: dict, changes: dict[str, str | None]) -> None:
"""Set the given keys; `None` values are deleted. One call."""
if not changes:
raise ValueError("nothing to set")
opts = []
for key, value in changes.items():
if value is not None:
opts += [f"--{key}", value]
gone = [k for k, v in changes.items() if v is None]
if gone:
opts += ["--delete", ",".join(gone)]
run(
"set", kind(guest), "config", guest["node"], str(guest["vmid"]), *opts, "--wait"
)
def startup_value(order: int | None, up: int | None, down: int | None) -> str | None:
"""`order=N[,up=S][,down=S]` from the given parts; None when none is given."""
parts = []
for name, value in (("order", order), ("up", up), ("down", down)):
if value is None:
continue
if type(value) is not int or value < 0:
raise ValueError(f"startup {name} must be a whole number >= 0")
parts.append(f"{name}={value}")
return ",".join(parts) or None
def parse_startup(text: str | None) -> tuple[int | None, int | None, int | None]:
"""(order, up, down) of a startup string; a bare leading number is the order."""
got: dict[str, int] = {}
for i, part in enumerate((text or "").split(",")):
key, sep, value = part.partition("=")
if not sep and i == 0 and key.strip().isdecimal():
key, value = "order", key
if key.strip() in ("order", "up", "down") and value.strip().isdecimal():
got[key.strip()] = int(value)
return got.get("order"), got.get("up"), got.get("down")
_TAG = re.compile(r"[A-Za-z0-9_][A-Za-z0-9_+.-]*")
def tags_value(text: str) -> str | None:
"""Tags split on `;`, `,` or whitespace, deduplicated, `;`-joined; None when empty."""
tags = list(dict.fromkeys(re.split(r"[;,\s]+", text.strip())))
tags = [t for t in tags if t]
for tag in tags:
if not _TAG.fullmatch(tag):
raise ValueError(
f"invalid tag {tag!r}: letters, digits, _ + . - (not starting with + . -)"
)
return ";".join(tags) or None
def not_backed_up() -> set[int]:
"""VMIDs no backup job covers (Proxmox's own answer, incl. pool-based jobs)."""
items = json.loads(run("get", "cluster", "not-backed-up", "-o", "json"))
return {int(i["vmid"]) for i in items if i.get("vmid") is not None}
_SNAPNAME = re.compile(r"[A-Za-z][A-Za-z0-9_-]{0,39}")
def valid_snapname(name: str) -> str:
"""The name unchanged, or ValueError; `current` is Proxmox's pseudo entry."""
if not _SNAPNAME.fullmatch(name) or name.lower() == "current":
raise ValueError(
"snapshot name: a letter, then letters, digits, _ or - (max 40); not 'current'"
)
return name
def snapshots(guest: dict) -> list[dict]:
"""The guest's snapshots without the `current` pseudo entry, newest first."""
out = run(
"get", "guest", "snapshots", guest["node"], _api_type(guest),
str(guest["vmid"]), "-o", "json",
) # fmt: skip
found = [s for s in json.loads(out) if s["name"].lower() != "current"]
return sorted(found, key=lambda s: s.get("snaptime") or 0, reverse=True)
def valid_description(text: str) -> str:
"""Passed as a positional argument: a leading '-' could be read as an option."""
if text.startswith("-"):
raise ValueError("description must not start with '-'")
return text
def take_snapshot(
guest: dict, name: str, description: str = "", vmstate: bool = False
) -> None:
opts = ["--vmstate", "1"] if vmstate and kind(guest) == "vm" else []
run(
"create", kind(guest), "snapshot", guest["node"], str(guest["vmid"]),
valid_snapname(name), valid_description(description) or "cv4pve-tui", *opts, "--wait",
) # fmt: skip
def rollback_snapshot(guest: dict, name: str, start: bool = False) -> None:
opts = ["--start", "1"] if start else []
run(
"do", "rollback", kind(guest), guest["node"], str(guest["vmid"]),
valid_snapname(name), *opts, "-y", "--wait",
) # fmt: skip
def delete_snapshot(guest: dict, name: str) -> None:
"""DANGER: without the `snapshot` word `delete <kind> <node> <vmid> -y` destroys the guest."""
args = (
"delete", kind(guest), "snapshot", guest["node"], str(guest["vmid"]),
valid_snapname(name), "-y", "--wait",
) # fmt: skip
if args[2] != "snapshot" or args[4:6] != (str(guest["vmid"]), name):
raise RuntimeError("refusing to send a delete that is not a snapshot delete")
run(*args)
def tasks() -> list[dict]:
"""Recent cluster tasks, newest first; running ones lack endtime and status."""
items = json.loads(run("api", "get", "/cluster/tasks", "-o", "json"))
return sorted(items, key=lambda t: t.get("starttime") or 0, reverse=True)
def nodes() -> list[str]:
"""Names of the online nodes, sorted."""
items = json.loads(run("get", "nodes", "-o", "json"))
return sorted(n["node"] for n in items if n.get("status") == "online")
def package_updates(node: str) -> list[dict]:
"""Pending package updates of a node (as of its last `apt update`), by package."""
items = json.loads(run("get", "node", "packages", node, "-o", "json"))
return sorted(items, key=lambda p: p.get("Package", ""))
def task_log(upid: str) -> str:
if not isinstance(upid, str) or not upid.startswith("UPID:"):
raise ValueError("not a task id (UPID)")
return run("task", "log", upid)
def version() -> str:
return run("--version").strip()
+3803 -10
View File
File diff suppressed because it is too large. Load diff
+1136
View File
File diff suppressed because it is too large. Load diff
+288
View File
@@ -0,0 +1,288 @@
"""Tests for scripts/pct-shrink-rootfs; spec: docs/plans/lxc-shrink.md (section B).
The script runs for real, but pct, df, rsync, flock, ha-manager, pvesr and id are
stubs first on PATH. The pct stub edits a temp <vmid>.conf the way PVE does (mp9 set,
mpN -> first free unusedK on delete, lock: mounted on mount); every stub logs its argv.
"""
import os
import subprocess
from pathlib import Path
import pytest
SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "pct-shrink-rootfs"
GIB = 1024**3
STUBS = {
"id": 'echo "${STUB_UID:-0}"',
"flock": 'echo "flock $*" >> "$STUB_LOG"\nshift\nexec "$@"',
"ha-manager": 'echo "${STUB_HA_OUT:-}"',
"pvesr": 'echo "${STUB_PVESR_OUT:-}"',
"df": """echo "df $*" >> "$STUB_LOG"
# like GNU df: -i and --output cannot be combined
if [[ " $* " == *" -i "* && "$*" == *--output* ]]; then echo "df: options -i and --output are mutually exclusive" >&2; exit 1; fi
if [[ "$*" == *--output=iused* ]]; then echo IUsed; echo "${STUB_IUSED:-1000}"
else echo Used; echo "${STUB_USED:-1073741824}"; fi""",
"rsync": """echo "rsync $*" >> "$STUB_LOG"
[[ -z ${STUB_RSYNC_FAIL:-} ]]""",
"pct": """echo "pct $*" >> "$STUB_LOG"
conf="$PCT_CONF_DIR/$2.conf"
case "$1" in
status) echo "status: ${STUB_STATUS:-stopped}" ;;
config) cat "$conf" ;;
mount) echo "lock: mounted" >> "$conf" ;;
unmount)
n=$(grep -c '^pct unmount' "$STUB_LOG")
[[ ${STUB_FAIL_UNMOUNT:-0} == "$n" ]] && exit 1
grep -vx 'lock: mounted' "$conf" > "$conf.new"; mv "$conf.new" "$conf" ;;
set)
if [[ $3 == --mp9 ]]; then
spec=${4%%,*}; storage=${spec%%:*}; gib=${spec#*:}
vol=${STUB_NEW_VOLID:-$storage:vm-$2-disk-1}
echo "mp9: $vol,mp=/.shrink-new,size=${gib}G" >> "$conf"
else
key=$4
line=$(grep "^$key: " "$conf")
grep -v "^$key: " "$conf" > "$conf.new"
if [[ $key == mp* ]]; then
vol=${line#*: }; vol=${vol%%,*}; k=0
while grep -q "^unused$k: " "$conf.new"; do k=$((k+1)); done
echo "unused$k: $vol" >> "$conf.new"
fi
mv "$conf.new" "$conf"
fi ;;
esac""",
}
BASE = """\
arch: amd64
cores: 2
hostname: web
memory: 512
net0: name=eth0,bridge=vmbr0,ip=dhcp
ostype: debian
rootfs: local-lvm:vm-101-disk-0,size=8G,acl=1
swap: 512
unused0: local-lvm:vm-101-disk-5
unused2: local-lvm:vm-101-disk-7
"""
class Node:
def __init__(self, tmp: Path):
self.bin, self.confdir, self.backup = tmp / "bin", tmp / "lxc", tmp / "backup"
for d in (self.bin, self.confdir, self.backup):
d.mkdir()
for name, body in STUBS.items():
(self.bin / name).write_text(f"#!/usr/bin/env bash\n{body}\n")
(self.bin / name).chmod(0o755)
self.conf = self.confdir / "101.conf"
self.log = tmp / "calls.log"
self.log.touch()
self.conf.write_text(BASE)
def run(self, *args, **env):
full = {
**os.environ,
"PATH": f"{self.bin}:{os.environ['PATH']}",
"PCT_CONF_DIR": str(self.confdir),
"PCT_SHRINK_BACKUP_DIR": str(self.backup),
"STUB_LOG": str(self.log),
**env,
}
return subprocess.run(
[str(SCRIPT), *args], check=False, env=full, capture_output=True, text=True
)
def calls(self, *prefixes):
return [x for x in self.log.read_text().splitlines() if x.startswith(prefixes)]
@pytest.fixture
def node(tmp_path):
return Node(tmp_path)
MUTATING = ("pct set", "rsync", "flock")
# name: (extra conf text, replacement for the rootfs line or None, env, stderr keyword)
REFUSALS = {
"not-root": ("", None, {"STUB_UID": "1000"}, "root"),
"running": ("", None, {"STUB_STATUS": "running"}, "stopped"),
"snapshot": ("[snap1]\ncores: 1\n", None, {}, "section"),
"pending": ("[pve:pending]\ncores: 4\n", None, {}, "section"),
"lock": ("lock: backup\n", None, {}, "lock"),
"template": ("template: 1\n", None, {}, "template"),
"protection": ("protection: 1\n", None, {}, "protection"),
"ha": ("", None, {"STUB_HA_OUT": "service ct:101 (node1, started)"}, "HA"),
"replication": (
"",
None,
{"STUB_PVESR_OUT": "101-0 node2 */15 - yes"},
"replication",
),
"bind-rootfs": ("", "rootfs: /srv/ct101,size=8G", {}, "not a volume"),
"mp": ("mp0: local-lvm:vm-101-disk-2,mp=/data,size=1G\n", None, {}, "mount point"),
"mount-entry": ("lxc.mount.entry: /a b none bind 0 0\n", None, {}, "mount point"),
"too-full": ("", None, {"STUB_USED": str(2 * GIB)}, "too full"),
"too-many-inodes": ("", None, {"STUB_IUSED": "200000"}, "inodes"),
}
@pytest.mark.parametrize("name", REFUSALS)
def test_refusal_changes_nothing(node, name):
extra, rootfs, env, keyword = REFUSALS[name]
text = BASE + extra
if rootfs:
text = text.replace("rootfs: local-lvm:vm-101-disk-0,size=8G,acl=1", rootfs)
node.conf.write_text(text)
before = node.conf.read_bytes()
r = node.run("--yes", "101", "2", **env)
assert r.returncode != 0
assert keyword in r.stderr
assert node.conf.read_bytes() == before
assert node.calls(*MUTATING) == []
assert list(node.backup.iterdir()) == []
@pytest.mark.parametrize(
"args",
[
("101", "8"),
("101", "9"),
("101", "0"),
("101", "abc"),
("101", "-1"),
("101", "1.5"),
("101",),
("101", "2", "3"),
("abc", "2"),
],
)
def test_bad_arguments_or_size_not_smaller(node, args):
before = node.conf.read_bytes()
r = node.run("--yes", *args)
assert r.returncode != 0
assert r.stderr.strip()
assert node.conf.read_bytes() == before
assert node.calls(*MUTATING) == []
def test_missing_config_refused(node):
r = node.run("--yes", "999", "2")
assert r.returncode != 0
assert "999.conf" in r.stderr
def test_pct_missing_refused(node):
(node.bin / "pct").unlink()
# Minimal PATH, so a real pct on the host cannot be found either.
r = node.run("--yes", "101", "2", PATH=f"{node.bin}:/usr/bin:/bin")
assert r.returncode != 0
assert "pct not found" in r.stderr
assert node.conf.read_bytes() == BASE.encode()
def test_dry_run_leaves_config_and_prints_plan(node):
r = node.run("101", "2", STUB_HA_OUT="service ct:1010 (node1, started)")
assert r.returncode == 0, r.stderr
assert node.conf.read_bytes() == BASE.encode()
assert node.calls("pct mount", "pct unmount") == [
"pct mount 101",
"pct unmount 101",
]
assert node.calls(*MUTATING) == []
assert str(node.confdir) in r.stdout
assert str(node.backup) in r.stdout
assert "--yes" in r.stdout
def run_happy(node, **env):
r = node.run("--yes", "101", "2", **env)
assert r.returncode == 0, r.stderr
return r
def test_happy_path_exact_config_and_call_sequence(node):
r = run_happy(node)
assert (
node.conf.read_text()
== BASE.replace(
"rootfs: local-lvm:vm-101-disk-0,size=8G,acl=1",
"rootfs: local-lvm:vm-101-disk-1,size=2G,acl=1",
)
+ "unused1: local-lvm:vm-101-disk-0\n"
)
assert node.calls("pct", "rsync") == [
"pct status 101",
"pct mount 101",
"pct unmount 101",
"pct set 101 --mp9 local-lvm:2,mp=/.shrink-new",
"pct mount 101",
(
"rsync -aHAX --numeric-ids --whole-file --sparse --one-file-system"
" --exclude=/.shrink-new /var/lib/lxc/101/rootfs/ /var/lib/lxc/101/rootfs/.shrink-new/"
),
"pct unmount 101",
]
assert (
node.log.read_text()
.splitlines()[-1]
.startswith("flock /run/lock/lxc/pve-config-101.lock")
)
backups = list(node.backup.glob("pct-shrink-101-*.conf"))
# Copied under the lock just before the swap, so it already holds mp9.
mp9 = "mp9: local-lvm:vm-101-disk-1,mp=/.shrink-new,size=2G\n"
assert [b.read_text() for b in backups] == [BASE + mp9]
assert "unused1: local-lvm:vm-101-disk-0" in r.stdout
assert "pct set 101 --delete unused1" in r.stdout
assert "chattr" in r.stdout
def test_happy_path_dir_style_volid(node):
node.conf.write_text(
BASE.replace("local-lvm:vm-101-disk-0", "local:101/vm-101-disk-0.raw")
.replace("unused0: local-lvm:vm-101-disk-5\n", "")
.replace("unused2: local-lvm:vm-101-disk-7\n", "")
)
run_happy(node, STUB_NEW_VOLID="local:101/vm-101-disk-1.raw")
assert (
"rootfs: local:101/vm-101-disk-1.raw,size=2G,acl=1\n" in node.conf.read_text()
)
assert node.conf.read_text().endswith("unused0: local:101/vm-101-disk-0.raw\n")
assert "mp9" not in node.conf.read_text()
def test_rsync_failure_frees_new_volume_by_value(node):
r = node.run("--yes", "101", "2", STUB_RSYNC_FAIL="1")
assert r.returncode != 0
assert node.conf.read_bytes() == BASE.encode()
# mp9 moved to the first free unusedK (1), which is found by value and deleted
assert node.calls("pct")[-3:] == [
"pct unmount 101",
"pct set 101 --delete mp9",
"pct set 101 --delete unused1",
]
assert node.calls("flock") == []
assert list(node.backup.iterdir()) == []
def test_unmount_failure_while_measuring_stops_before_any_set(node):
r = node.run("--yes", "101", "2", STUB_FAIL_UNMOUNT="1")
assert r.returncode != 0
assert "pct unlock 101" in r.stderr
assert node.calls("pct unmount") == ["pct unmount 101"]
assert node.calls(*MUTATING) == []
def test_unmount_failure_after_rsync_stops_without_further_set(node):
r = node.run("--yes", "101", "2", STUB_FAIL_UNMOUNT="2")
assert r.returncode != 0
assert "pct unlock 101" in r.stderr
assert node.calls("pct set") == ["pct set 101 --mp9 local-lvm:2,mp=/.shrink-new"]
assert node.calls("flock") == []
text = node.conf.read_text()
assert "rootfs: local-lvm:vm-101-disk-0,size=8G,acl=1" in text
assert "mp9: local-lvm:vm-101-disk-1" in text
assert list(node.backup.iterdir()) == []
Generated
+119
View File
@@ -21,8 +21,10 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "hypothesis" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "ruff" },
]
[package.metadata]
@@ -30,8 +32,91 @@ requires-dist = [{ name = "textual", specifier = ">=8.2" }]
[package.metadata.requires-dev]
dev = [
{ name = "hypothesis", specifier = ">=6.170.14" },
{ name = "pytest", specifier = ">=8" },
{ name = "pytest-asyncio", specifier = ">=1.0" },
{ name = "ruff", specifier = "==0.17.3" },
]
[[package]]
name = "hypothesis"
version = "6.170.14"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "sortedcontainers" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d6/0f/10b2076a1314c554eee58c26e1dbe630fd26a32773df2a63bd5dba5d9947/hypothesis-6.170.14.tar.gz", hash = "sha256:8272913eb34c4399a88cf8a9ecdb351c9ef8f621c061f2ac323362d4a66b755d", size = 521490, upload-time = "2026-10-30T06:51:55.943Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/d6/eae1191f3398b8cc9f10b20c9b93dd1ceae037565e94ababf521730e59a4/hypothesis-6.170.14-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b1c4990f9fafad83cdac33bc564d41066f5824a92ddd45ded7e0e84277a3ec0e", size = 800123, upload-time = "2026-10-30T06:50:33.682Z" },
{ url = "https://files.pythonhosted.org/packages/56/78/434915150cecd5b9ba41837106a3da2782f515a9aed583d25860bfa6d71c/hypothesis-6.170.14-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:ab414b45a56853fcdd6b602d9c5033e3c7d127ee8b8e7e3397223b746440b37c", size = 795278, upload-time = "2026-10-30T06:50:22.237Z" },
{ url = "https://files.pythonhosted.org/packages/95/6b/07f5e5e667b4b3e90bea16b4db09367b5bb1dfae63eb75a7152b053b0c50/hypothesis-6.170.14-cp311-abi3-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:cceb1b3c19ea0a146c7bb2fb2b44e3ef231bcecd42c97d95154e5d233e209572", size = 1162031, upload-time = "2026-10-30T06:50:08.828Z" },
{ url = "https://files.pythonhosted.org/packages/22/ba/51e0dc10befcc4ca9b7fb81b76ca6aef4ffc9c7a741191a8b41e878ee2f2/hypothesis-6.170.14-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:376dbf5ffa1e3ad71af2a1af0877176faabe3e63d345324a33c82e408dbfd604", size = 1124181, upload-time = "2026-10-30T06:51:51.244Z" },
{ url = "https://files.pythonhosted.org/packages/ee/37/77834f102b0bca702f723dd519b8f590e83ec6392e59565384cfdc5bbec7/hypothesis-6.170.14-cp311-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:5f0f6f6666fa15ec13ca1d8430f70660fce8b3f4f133e25fdf674be6ad77295e", size = 1154664, upload-time = "2026-10-30T06:51:50.027Z" },
{ url = "https://files.pythonhosted.org/packages/12/76/83edc11f33319a2febd025fb504aa1ed8e245b7e25905da80513615e4b5e/hypothesis-6.170.14-cp311-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c4ff70fbb94f54cd35045a40e5a77dd8f876d4cc6a39dcdc4ac1af6f8f5cc6fe", size = 1148576, upload-time = "2026-10-30T06:50:18.505Z" },
{ url = "https://files.pythonhosted.org/packages/ce/35/d4086d546c0abe406f0ece17e33eca7051e87dc7501485a696385aed34fd/hypothesis-6.170.14-cp311-abi3-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:8ecec6be3a02e22c70bc7dfb15afeeeb708aeccb994c9d57db438199cad9bfbb", size = 1201336, upload-time = "2026-10-30T06:50:17.221Z" },
{ url = "https://files.pythonhosted.org/packages/dd/85/117b0dde708240854ce0f1ad95d43bc132f59e6bffc82c2a20e76b984020/hypothesis-6.170.14-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3bbf06c4f8e3aab37c144fe3bc1d89a051a2dfbefef8c2a25160b11da854f949", size = 1167592, upload-time = "2026-10-30T06:50:23.393Z" },
{ url = "https://files.pythonhosted.org/packages/29/bb/105ee91bf92e3b7a5bc388827e774b7c5744f39ce36f50f94918b584813c/hypothesis-6.170.14-cp311-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:07869d66bf1e92e39447808fcc1977bfd10bfdff4970d6969a49e89a84cf04a7", size = 1123748, upload-time = "2026-10-30T06:51:07.034Z" },
{ url = "https://files.pythonhosted.org/packages/cd/9b/c60e868ba0f93ff5bc7cec3e413c5e824863bbd9bd45dcc86bc18adb4266/hypothesis-6.170.14-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0ce9ddf0d0e99c532c2cf00d97b63316830810d18f11c80c7bcf0f771969f3a4", size = 1299121, upload-time = "2026-10-30T06:51:00.068Z" },
{ url = "https://files.pythonhosted.org/packages/e9/f6/a2b9630ddc70aa7642931ac6c353fc57df38c6de655718a50337b5b7d4e0/hypothesis-6.170.14-cp311-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:420b4a77157e94f124498fff639284d514b60926f61925dd7aafb75411e7ce92", size = 1427779, upload-time = "2026-10-30T06:50:15.652Z" },
{ url = "https://files.pythonhosted.org/packages/db/36/b3e7d3fef4ce3f44f70a61399a3549c02a71e430122f688880a24336e9a6/hypothesis-6.170.14-cp311-abi3-musllinux_1_2_i686.whl", hash = "sha256:2a8ed08715a9a27d3a4730f48c99133ef25b3561d8fe992d1eda91e22cf6be99", size = 1381908, upload-time = "2026-10-30T06:50:58.685Z" },
{ url = "https://files.pythonhosted.org/packages/ad/52/b044ccaf1cb9d89f319b737f1fee7cc83e16c2fd086e1bf1ce149092eb44/hypothesis-6.170.14-cp311-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:71081b46714e95e9a10d922bedd08d5a6e49835bbc2ef37a81d30d62f8596829", size = 1277802, upload-time = "2026-10-30T06:51:53.644Z" },
{ url = "https://files.pythonhosted.org/packages/27/2f/989ef755683c7cefd195670861a2fbd72b6b7952c124256152e94660e62c/hypothesis-6.170.14-cp311-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:ce563cb34dedc9163811b03ce05a955eededd6f429db7c80c5c31d5836a9c05a", size = 1293571, upload-time = "2026-10-30T06:50:03.076Z" },
{ url = "https://files.pythonhosted.org/packages/a5/00/7426641c28dfc52e0a0d32748a1e47d3c189f9cf7eef81c0ecb7e90eb4cc/hypothesis-6.170.14-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:06a752e3e879f1b69dda9ea203d4e8e1562cf72f694513f01c8c99d2e5e3f3cc", size = 1333329, upload-time = "2026-10-30T06:50:50.362Z" },
{ url = "https://files.pythonhosted.org/packages/7b/55/88307f535bb0914824b0eb2c6b58aa4a482b483c8d25d8022e7b4f641f8c/hypothesis-6.170.14-cp311-abi3-win32.whl", hash = "sha256:0f418c6bff7cd8a117dd0f526c20f100c32df1ff9f7bc8e01a5eeb3855278039", size = 687305, upload-time = "2026-10-30T06:50:43.079Z" },
{ url = "https://files.pythonhosted.org/packages/25/61/ba5bb3e8593afa9212fad51dc70f219ef6da7527d806c62a42c1cf12247b/hypothesis-6.170.14-cp311-abi3-win_amd64.whl", hash = "sha256:1e9f7619dff3072685e4f36e8ae7b134a0c1ca41074fc54ade81e5355c72cd8c", size = 693378, upload-time = "2026-10-30T06:51:36.729Z" },
{ url = "https://files.pythonhosted.org/packages/ab/72/482a68ae0e94b24acd964196cca830d1dd65efbf3d4821599d1c9527f8e2/hypothesis-6.170.14-cp311-abi3-win_arm64.whl", hash = "sha256:7dbaec9a62b7792b0b04b7ebc933aeafd3ff019b81d67d763c36ebcf4d56f864", size = 691696, upload-time = "2026-10-30T06:50:32.53Z" },
{ url = "https://files.pythonhosted.org/packages/8e/41/aa2c8ffc1490f02ec2b0e283e0856a0822c2b0707a83831e286493f888f5/hypothesis-6.170.14-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:76b03ded37a7eb14d01b03edca02b9a6ce6b8ab0ad8d2f9ac5f2e9de6769a33a", size = 801893, upload-time = "2026-10-30T06:51:10.965Z" },
{ url = "https://files.pythonhosted.org/packages/81/45/a82212e5773214d3ad1e0cee626098c1068adc93cdb37449e3c0629aa591/hypothesis-6.170.14-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a6055172afdca41cda869652bd2f2c24ec984005cd16bbec5a8f38483f23b9bc", size = 793892, upload-time = "2026-10-30T06:50:55.958Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cc/1889082d37e946e006845163a91e4091d1cff4f1692e3580729454cb02f4/hypothesis-6.170.14-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6490c64811a2176a5af9fe5a5d59ab2aa20d35d691328cbb336f6fb398355c4", size = 1123577, upload-time = "2026-10-30T06:50:11.524Z" },
{ url = "https://files.pythonhosted.org/packages/7f/7a/311a4ec14dbde1ce82cbd4d8957665c4d68e9139524b10e0a622db98bc4e/hypothesis-6.170.14-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cde7e98694a9baa1d9761c1b595b3d617467c876200c0798a40f2016a0e85304", size = 1167206, upload-time = "2026-10-30T06:50:05.235Z" },
{ url = "https://files.pythonhosted.org/packages/f5/0d/1afb5e9f470898cf296f10545cfbf76e6b8d4d7025e0c41a491bbe969a22/hypothesis-6.170.14-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e39d46bdebb3853fb3000aab3c354e4d780705dc8b2674773ffcdfd04fd5122", size = 1298455, upload-time = "2026-10-30T06:51:30.733Z" },
{ url = "https://files.pythonhosted.org/packages/f5/f6/ff7e9568f7a7d9ec375c68636c66e1b1a3baa06f44652535ff434110f6cd/hypothesis-6.170.14-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1309925a71a2baacb85ed5c20291ddb4b2a4665bd33fb594697463d457374a1b", size = 1332876, upload-time = "2026-10-30T06:50:57.14Z" },
{ url = "https://files.pythonhosted.org/packages/cc/73/fd330eec15026dc30d0ec8c658517c99703774098305632efb3e523b2f38/hypothesis-6.170.14-cp312-cp312-win_amd64.whl", hash = "sha256:ac98e833bdefbcbebb9094180a7431a8946f1acc91a5b779c814aecca71c7345", size = 691003, upload-time = "2026-10-30T06:50:36.624Z" },
{ url = "https://files.pythonhosted.org/packages/88/95/9729cc49645d07211ce8158f0bb51860873b3b99fb1650c2dad78bd2e86c/hypothesis-6.170.14-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7d5e5affa9e69b8673f15bef86421740945e74f3c31d0817141ba020c7e00705", size = 801888, upload-time = "2026-10-30T06:50:06.456Z" },
{ url = "https://files.pythonhosted.org/packages/6e/ac/687277e019d68b96d2c33b979ad8a955b5747e279891f8c335bbf58cd9bc/hypothesis-6.170.14-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7ae9ff3220659c4721acb58d34e72227a0af7747fc903ae177dfdb0323796b4", size = 793879, upload-time = "2026-10-30T06:51:09.51Z" },
{ url = "https://files.pythonhosted.org/packages/b2/1a/b46a2959b41ff9409af8b43fd63b07c6ed51958db0e9d314db9ee6012f5a/hypothesis-6.170.14-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:857932ff6bba183af0236f659b042023ab8b4ad58725b9024bcaba941c8cd5d0", size = 1123483, upload-time = "2026-10-30T06:50:47.998Z" },
{ url = "https://files.pythonhosted.org/packages/cb/2a/6735b2edff23d045441b5917f35c01a4c139a186604847ca19bbd63e81e2/hypothesis-6.170.14-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8126e8208acadfc3c92d0aedb8011cf724fb59e5523ceaca3f4612dcc409e632", size = 1167158, upload-time = "2026-10-30T06:51:26.528Z" },
{ url = "https://files.pythonhosted.org/packages/ea/f0/b5b4273568620080db24dda2008236bd6c64599ab03210e46977b44b54d5/hypothesis-6.170.14-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d28ab5c5b08b1de11bc022d576b20f9f585f7e7758778295461f80b9222d7a18", size = 1298440, upload-time = "2026-10-30T06:50:10.195Z" },
{ url = "https://files.pythonhosted.org/packages/2a/c7/38427a9803dac9bdd06581061a84672107d821f6d91b833dec467c37f5d5/hypothesis-6.170.14-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:033aca79fd3f9a6e15402dfddbc988141d43eae38d26a5fc554f6879f7e0e5f9", size = 1332909, upload-time = "2026-10-30T06:51:17.244Z" },
{ url = "https://files.pythonhosted.org/packages/7a/4f/47c060c96d077750c8a7b83b2e50b1add2050b391e77f49126828c125264/hypothesis-6.170.14-cp313-cp313-win_amd64.whl", hash = "sha256:03831d833b161b9b7a32f0b607205293954f3a651196c80138804b9479845d71", size = 690983, upload-time = "2026-10-30T06:51:04.201Z" },
{ url = "https://files.pythonhosted.org/packages/e8/3a/7ce36c91aafb192e08d990b3a8fd632be113eeb43973a94fddaa4172c570/hypothesis-6.170.14-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:a2410c5a6dcc011338e53cd381bd32d979b6f06d799c5cf01d76b923f0a0670b", size = 802098, upload-time = "2026-10-30T06:51:48.445Z" },
{ url = "https://files.pythonhosted.org/packages/c7/dc/17bc4ba00a48976944918bb5d7207519ed206fdf98af0d0319e5ba33afd2/hypothesis-6.170.14-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcf6303c65e73acf134b7854cbdfec8f2108cfdc84806fa180de04d7946aab0c", size = 793921, upload-time = "2026-10-30T06:50:07.623Z" },
{ url = "https://files.pythonhosted.org/packages/1c/c3/252db9362dab21e3c3351abb89e715fa1ef598ebaf30abb9c9b1dd3b3aab/hypothesis-6.170.14-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:67d17e8b91d3913e7aa2b1179957df4e0634caea3868b5ba4fe2bf1945334134", size = 1123996, upload-time = "2026-10-30T06:51:05.628Z" },
{ url = "https://files.pythonhosted.org/packages/f8/41/573468979e9579eb6df937e6a96c1a944dfe2765c73624156571e3cc4b34/hypothesis-6.170.14-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:96f9f8a684c88ad2280129b28bc609adb6b31cd63b1b83b397b86d552391e947", size = 1167330, upload-time = "2026-10-30T06:51:23.741Z" },
{ url = "https://files.pythonhosted.org/packages/f6/1c/d1b790ec3247966ceb3d5729bb79f810e24b1684b49b6290c9ad5be05e33/hypothesis-6.170.14-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c42d504d9bc0aef71fa88877c68ce202c02242c84f05791be2785e01e3a8699", size = 1298940, upload-time = "2026-10-30T06:51:27.68Z" },
{ url = "https://files.pythonhosted.org/packages/f6/a3/2fb6f7e825c9d943c5c35e0d9ef968e416267db2ef710e6d2d3cf7f36ab0/hypothesis-6.170.14-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ad84ca98f38e9f385b0f616df04bea4a9c6849333e221ce9289e9566ccd41058", size = 1333102, upload-time = "2026-10-30T06:51:20.993Z" },
{ url = "https://files.pythonhosted.org/packages/49/09/b533e4ac06dc317314d574dd0e3b548bac9cffe05e7e7a32f7b25aa7f052/hypothesis-6.170.14-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f569bd9745835f9864d53c853e401c2021e796fdbb385497fdae633fe950b6ba", size = 633715, upload-time = "2026-10-30T06:50:25.715Z" },
{ url = "https://files.pythonhosted.org/packages/08/c6/9c47f183e5a8a4059d7c1c39e85f7b140f4416961b5ed0dac5d35dc6e1f7/hypothesis-6.170.14-cp314-cp314-win_amd64.whl", hash = "sha256:31c6a4015c02c45ba0285ff48538f4eb2c2de7e836f313bb8832fd8d86f71d62", size = 690702, upload-time = "2026-10-30T06:50:41.998Z" },
{ url = "https://files.pythonhosted.org/packages/05/47/623b189cc8d9e360bfbb69828fb88ca4d0d4996b3975f3191b49a50f04ce/hypothesis-6.170.14-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:744c587791b2c5e38d1da2eaca617b1a82073832dfcf42975ddb03e93f187919", size = 800266, upload-time = "2026-10-30T06:50:29.462Z" },
{ url = "https://files.pythonhosted.org/packages/ea/af/7ec2697869df2c7f5d1c414b1a0a21746aa0298d41d7358e45fb7ed83992/hypothesis-6.170.14-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d0daded6c2f3e05f6bd989059bb798e570722802e05eb3543c6f5d7a1e7e6500", size = 792500, upload-time = "2026-10-30T06:50:51.878Z" },
{ url = "https://files.pythonhosted.org/packages/e0/cf/2c7b13b5e56cd42789dbef4948f21e685c4a4db7dd6daffa253f261a9193/hypothesis-6.170.14-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:986eaec8ca5a22710b1086b88734035c7d7ffcdb4ce02341b9f7069cc1bdfc5e", size = 1122469, upload-time = "2026-10-30T06:51:31.981Z" },
{ url = "https://files.pythonhosted.org/packages/63/17/aab31d83a3195edf21ff0b05ea8fabf1fcf723517a6ba7bd1aee3b2713e3/hypothesis-6.170.14-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44c481115770bb3df5014b0369ea1aaf7319b81b3346a744a9265fc933190c3d", size = 1166369, upload-time = "2026-10-30T06:50:54.572Z" },
{ url = "https://files.pythonhosted.org/packages/8b/ce/88ab370d5c1e06c74b87995381c28f5c1456bcd832db339bb93b6b15cae5/hypothesis-6.170.14-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:261ae20a5573cf59e5dbf931c7fb9abcb012695adccb61fdab108cbba1772ee5", size = 1297198, upload-time = "2026-10-30T06:51:22.283Z" },
{ url = "https://files.pythonhosted.org/packages/06/03/5c8d82f48d3a7adb593d1c141fdae597ef874e27dabd94ede9edd5c36c29/hypothesis-6.170.14-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:833f7e6d93c03f3c00583a126e61f631d6b91a84e157a30da2d1c786ce5658b7", size = 1332114, upload-time = "2026-10-30T06:51:34.61Z" },
{ url = "https://files.pythonhosted.org/packages/25/24/b08d06042af15e1a5fd2fe378feaa2336983f4ecc2bf645b38c6accf7d7d/hypothesis-6.170.14-cp314-cp314t-win_amd64.whl", hash = "sha256:224cad0e7ba8bfc6ef708652a3cc2148161e3157d191eb77ecc85dc5d16afb70", size = 690815, upload-time = "2026-10-30T06:51:43.881Z" },
{ url = "https://files.pythonhosted.org/packages/ee/72/bbd7a5341e23b56759937cef4fe83ea9c907f15ffccee1c2e8e2c8937546/hypothesis-6.170.14-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:e76877e7c7a5cf8c4fbf98280dc6582f5d6450a73494490e8b48b7d03fb5e12f", size = 799866, upload-time = "2026-10-30T06:50:12.929Z" },
{ url = "https://files.pythonhosted.org/packages/df/f0/be3b05a4c57bbb91b0048c37a5f3a278accb1ec51e853bb87037379c3f93/hypothesis-6.170.14-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:5a613867bd2675101dda8fda380c3ce6be47842bed5640e1ba7b6b6fd0ffe8a3", size = 792315, upload-time = "2026-10-30T06:50:37.847Z" },
{ url = "https://files.pythonhosted.org/packages/aa/2b/f0a1509d5d4ad2e3b3c834bebd8a4b1f430d01d350710f45338342756b91/hypothesis-6.170.14-cp315-abi3.abi3t-manylinux1_i686.manylinux_2_5_i686.whl", hash = "sha256:a2249f2428ea94c47bccceb9fb8beada242c577dd4b3cd2edef5fb7160d1b71b", size = 1158319, upload-time = "2026-10-30T06:50:46.745Z" },
{ url = "https://files.pythonhosted.org/packages/6c/5c/eea759905d4b7d4d9a750b9b61a419120742143288c48c7d0ba633efc5f1/hypothesis-6.170.14-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:20df34cca1265c1683ccdcab5872cf0a023f40235a3092a96d4bd81e4fc57986", size = 1121221, upload-time = "2026-10-30T06:50:28.317Z" },
{ url = "https://files.pythonhosted.org/packages/93/b0/3c90a9e734aa0a4aef0fa9004dd7c2bcd27a6e66bc32a8690647639cba88/hypothesis-6.170.14-cp315-abi3.abi3t-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:4a7f8023efbec61296fd20edb35d638ec89ff761d3f9d3dc8128ac8f204652ec", size = 1152108, upload-time = "2026-10-30T06:50:35.02Z" },
{ url = "https://files.pythonhosted.org/packages/63/fd/4b7e7f69ee248547f6fe69475197ffbb5ae9563ab49c1430f020bd1d5493/hypothesis-6.170.14-cp315-abi3.abi3t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3faa2b45f30320fb57ce1c30f1bf9f08b1fd1cc1c606ae2a3e1efd0256fd7c77", size = 1145869, upload-time = "2026-10-30T06:51:54.841Z" },
{ url = "https://files.pythonhosted.org/packages/15/de/dc96bc0646e8543386e3c539fea154075b2d0f40e6b3fb19022c43058dac/hypothesis-6.170.14-cp315-abi3.abi3t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5be7a2239adde6c72001eb42cdb924d3c3856b1ee80342049961efdd6c72d064", size = 1199697, upload-time = "2026-10-30T06:51:40.612Z" },
{ url = "https://files.pythonhosted.org/packages/f5/fd/363472210e3ed2a356b7bc6ca8ca405c0dae6531ad43c8f76ca20a946884/hypothesis-6.170.14-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1a7aa883cd30c6c0c7259f34c1e3f37617c0d52fa6718e93c9926cc49b8047d6", size = 1165533, upload-time = "2026-10-30T06:50:14.401Z" },
{ url = "https://files.pythonhosted.org/packages/37/3f/989ab6da164efe574399debdf64370d153f17d4cfda51c8d7e14781dc92b/hypothesis-6.170.14-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:79360d1dd671e602d2327e26fb9179645511adcfb9ec9d28c5d6bda313920aa2", size = 1119844, upload-time = "2026-10-30T06:51:08.317Z" },
{ url = "https://files.pythonhosted.org/packages/5d/c4/7758868c29d3fbcdd0face967a2d7e3af221a1c8d57802c7ac6cb49becd9/hypothesis-6.170.14-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:e4c45885e931a55c5097ebf0a9b65459878ad5e1c69c0448ae37e03fd764ddfc", size = 1296008, upload-time = "2026-10-30T06:50:40.598Z" },
{ url = "https://files.pythonhosted.org/packages/c7/b2/dc88869b6f8508fbfb290c4a9b03328142743d041ae4a144870b904b69bd/hypothesis-6.170.14-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:a6d09601401c019366826effa98ae64393dd149b50d7d6ea202ad472784278a8", size = 1425148, upload-time = "2026-10-30T06:51:18.492Z" },
{ url = "https://files.pythonhosted.org/packages/f2/f2/f6dde97e8a1d017d9663164f48f600f65e9d2ade37c43447421f6a369cbe/hypothesis-6.170.14-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:05e2a645b94fd43107a3360fe0aaf6db268199c80a43cf375fac5f1f9047745f", size = 1378730, upload-time = "2026-10-30T06:51:16.063Z" },
{ url = "https://files.pythonhosted.org/packages/9b/42/e2aa93586527428b51c019a4cedf28e1d2f10dfec5f9acfa3e1a22cd2e86/hypothesis-6.170.14-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:cb54946f70ae5008ea8cd58ce9a27b2126506732b53b83a6d117371d7c755362", size = 1274346, upload-time = "2026-10-30T06:51:14.2Z" },
{ url = "https://files.pythonhosted.org/packages/c2/22/17ee8d38c5cb0a948ba9bb6cd7be2351a021cf1ea6686aa4fac16c29c45a/hypothesis-6.170.14-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:b72805cf56012a3d96f4a1fbb869f178d448f242b4f64310956134a3c952113f", size = 1291604, upload-time = "2026-10-30T06:50:53.304Z" },
{ url = "https://files.pythonhosted.org/packages/0c/c8/c97cdaa1191519fadcc06bc118f271910d7d7972caeecb70b7003a62303c/hypothesis-6.170.14-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:a17500680ec2c3cf006c12d3832a6c8daba8a18386f93b3d1828de07b07910aa", size = 1331507, upload-time = "2026-10-30T06:51:02.784Z" },
{ url = "https://files.pythonhosted.org/packages/cf/c7/1007bdaf517b086df3c54d06de6c47f099704fd8bd6c2b5013e749833ee4/hypothesis-6.170.14-cp315-abi3.abi3t-win32.whl", hash = "sha256:b1d9baaef909100fd1a6ba5457ecffa4a184e820c7f0bd3d4589f14870f5cade", size = 684627, upload-time = "2026-10-30T06:50:49.194Z" },
{ url = "https://files.pythonhosted.org/packages/51/d1/68539ecbe240de4afc03c5fa120ecf0875690708f2a97914352ae3172d36/hypothesis-6.170.14-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:a725b854c5df088c5a579f1c323657258abe5d8e32e16cd07beb48960f31c070", size = 690617, upload-time = "2026-10-30T06:51:42.268Z" },
{ url = "https://files.pythonhosted.org/packages/66/d7/9671c708cdab1c49ff96344f6730ccd76a7e1c2bdbbb2bed93bc9d6a18b4/hypothesis-6.170.14-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:75f60ef38c010742339a9b2ec2a44cb0aed6c6c8f1455ad6a6de48f5216a72b2", size = 688595, upload-time = "2026-10-30T06:51:37.945Z" },
{ url = "https://files.pythonhosted.org/packages/ba/ae/822ac5f03f0f13e96b4a7b380ede8fa4ae2a46730f4e5c6d7eca016147bb/hypothesis-6.170.14-pp312-pypy312_pp80-macosx_10_12_x86_64.whl", hash = "sha256:c7b5dee8b87f8a78c1786eb1f96a6c0c91fcc2e10b2e9d876dcbc8e3f65dffc3", size = 801000, upload-time = "2026-10-30T06:50:44.17Z" },
{ url = "https://files.pythonhosted.org/packages/fa/b2/e4a0901adb647ca375a794cbf1a23e5e49e59c77d191d1922e3d3c4ce6b6/hypothesis-6.170.14-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:e99d1bc7d8e8fc17cb67a38e003cbf4a17f41f7851c1f205efd35b6f3ba93da5", size = 792896, upload-time = "2026-10-30T06:51:25.028Z" },
{ url = "https://files.pythonhosted.org/packages/d1/9c/ac50eed9becb0de590d8af7bd1b67546e58b84dda4abb89ca563eed1babe/hypothesis-6.170.14-pp312-pypy312_pp80-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d66dab3b79eda78964665ab4f9d7a269137c14cd6ec7ea4fb27002a8786452b9", size = 1122853, upload-time = "2026-10-30T06:51:33.262Z" },
{ url = "https://files.pythonhosted.org/packages/c4/7f/bc41905c23c74c3db4620a5382977b168156f357e8a421c2b9f66f764979/hypothesis-6.170.14-pp312-pypy312_pp80-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:88d829c29907e7d86f8b1d33041bb73814062a3681ec51dcb231f64874f88b14", size = 1167065, upload-time = "2026-10-30T06:51:45.442Z" },
{ url = "https://files.pythonhosted.org/packages/0f/17/b28b4348a95694562357dc0c8ecb521dd607051dddd7af158c754b895710/hypothesis-6.170.14-pp312-pypy312_pp80-win_amd64.whl", hash = "sha256:90d4e85f22c3154a59df851bb2c59c89c129fba3155125d98ebad80bf69aad26", size = 692119, upload-time = "2026-10-30T06:51:19.8Z" },
]
[[package]]
@@ -168,6 +253,40 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
]
[[package]]
name = "ruff"
version = "0.17.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/59/73/81f140c4edd9cc323de5478e6bbd3bbcfae9a82692df1a258de8cec6a6d2/ruff-0.17.3.tar.gz", hash = "sha256:a57049c8addb3662073859bb55a7ad35aa54f8a7c9ed268911d1333c5aadaaa9", size = 5339444, upload-time = "2026-10-27T00:34:05.914Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/8c/083365310c4e79c842f25bf5a842facbd9b0c0ec336f96dffa5074fe723d/ruff-0.17.3-py3-none-linux_armv6l.whl", hash = "sha256:ab86b84cd9d05e56d329742c0cabdcbe2d1d56c98f4a04aab9b5b66196febfb4", size = 10216522, upload-time = "2026-10-27T00:33:29.822Z" },
{ url = "https://files.pythonhosted.org/packages/72/26/4b2edf31cfebe9605d8f6ed8f1d6f376d3a53d4c9a123e62661c64c440f0/ruff-0.17.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e407b4a6852853a9a2cdce96963e94ad9775578bb44e7561a0f2829f3cc04b11", size = 10217786, upload-time = "2026-10-27T00:33:33.949Z" },
{ url = "https://files.pythonhosted.org/packages/a2/7e/537aba4b7f54397a374d2f6a6fc93e9c8015e18553f31a4a7b7dbced48b5/ruff-0.17.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b75cc113b3aca388933d8296b667b1ac401a80317ea3074c9b0a66541199da29", size = 9440362, upload-time = "2026-10-27T00:33:35.784Z" },
{ url = "https://files.pythonhosted.org/packages/3d/66/283e39734f66c8a5ebaa93c22eb7b7515779a0b1d5a5c0ad20e05c0edb41/ruff-0.17.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab5fa7ed5ce18554ea69d850ad162cbaa0c85fc47c08d83a569f806681c9a6ad", size = 10094281, upload-time = "2026-10-27T00:33:38.216Z" },
{ url = "https://files.pythonhosted.org/packages/21/4c/1ffc768497bd025a32c72152771e40c0b99bddc5cc6e4f50c7949a395346/ruff-0.17.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c673c170e27834d1262e3a9c1e97bdc9b9c3b180c63d89173ce69747232474c1", size = 10022543, upload-time = "2026-10-27T00:33:40.181Z" },
{ url = "https://files.pythonhosted.org/packages/73/c3/f90db547301080fcb82c92abf914c8bd57b89911d9b65a6c4cc6b52d0635/ruff-0.17.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a754c0341bc9ca151b7b335988b3263921c08859f84ca219b70cee825571b6f", size = 10683921, upload-time = "2026-10-27T00:33:42.182Z" },
{ url = "https://files.pythonhosted.org/packages/e5/98/92d4a8a26c6c96cf1a5b34dd59036d8a71e52d650aab065c58867e964e63/ruff-0.17.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4590b9d86c0b1543ebb09094254e191cd43fe8edaf330baa035aab03da251532", size = 11373408, upload-time = "2026-10-27T00:33:44.19Z" },
{ url = "https://files.pythonhosted.org/packages/43/02/1f24d2e3064c9753926deeb901afa57777b37ee46799b09ddd7aee6e5145/ruff-0.17.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bbde18beccd98c29a227c63c99022e3443b99258e08326df6425dfe0443705ef", size = 10977469, upload-time = "2026-10-27T00:33:46.179Z" },
{ url = "https://files.pythonhosted.org/packages/f0/20/fecedea2209f574ab92033b344e01a7f0c2dc76c6d6d19a14c9f8141173f/ruff-0.17.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7fa4b0f931cdbcb8a52044f370a3eb429fb42eb88807b0e800fcaa98dd42b24e", size = 10577491, upload-time = "2026-10-27T00:33:48.233Z" },
{ url = "https://files.pythonhosted.org/packages/33/36/b93fdf0bc6f84f0cd487aadfb0354deb40013669ea35ac3d26855de1e959/ruff-0.17.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:39405f341deec934b4289634324ad695f72df2970f9121582872eaf5b722f0f5", size = 10608748, upload-time = "2026-10-27T00:33:50.284Z" },
{ url = "https://files.pythonhosted.org/packages/5d/b7/7d3f5f14145daead1efb0b9a8289ed1daa100f419d60774375951723ce00/ruff-0.17.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:68074094f55ca3d29ab19491f640282f7a84061c4c7977aa5094b968ca457e9a", size = 10075057, upload-time = "2026-10-27T00:33:52.074Z" },
{ url = "https://files.pythonhosted.org/packages/b0/ed/93ff7a5909ee56e2bd4b982deae9e0efb2724417874a90c9e41ad66c6dd7/ruff-0.17.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:27bb571ac78fef178fe5118d2827df95c2aae74a48fff2b15f6acd03af5ff5bb", size = 10022431, upload-time = "2026-10-27T00:33:54.061Z" },
{ url = "https://files.pythonhosted.org/packages/9b/07/a83b9eeec8c1c3c0e4eda15f06c3a91001bfd2687ea2f07b750719bf9712/ruff-0.17.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:029c37cbd3de48ca37aa12117bead2d065f15da17d66bf84e0d4274013705db2", size = 10394903, upload-time = "2026-10-27T00:33:56.158Z" },
{ url = "https://files.pythonhosted.org/packages/af/b2/05c39857c2526fcbebe46ad93a8dc97424656dfef14d3ef933abe1ffd331/ruff-0.17.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:692fdb46d0fd9c71e380947dc5fc72f2c3f881073bc1f739e4579376231affbe", size = 10799780, upload-time = "2026-10-27T00:33:58.204Z" },
{ url = "https://files.pythonhosted.org/packages/ec/d9/47171a16739eb7a353936b31c20cf54340531e68c9af7d72fd7a784e235d/ruff-0.17.3-py3-none-win32.whl", hash = "sha256:46539cb031b01fd29ae0a02142483c46640b233ee12bb1c1b690bffa1555d5ca", size = 9908481, upload-time = "2026-10-27T00:34:00.087Z" },
{ url = "https://files.pythonhosted.org/packages/d0/a0/f08c1631bfd81faa6a90eaf70c37c786c11bed2046302b56bb506c25e1a0/ruff-0.17.3-py3-none-win_amd64.whl", hash = "sha256:56b8ffb3f3b3cbccb8b76b71b38261b7ed982ef5d20f5b447b1b61a7f4949b24", size = 10341333, upload-time = "2026-10-27T00:34:02.099Z" },
{ url = "https://files.pythonhosted.org/packages/99/d5/4ad4ed31413c301430918e70d3d38ae6f097af0766cb9ab8c9d502b2a4d6/ruff-0.17.3-py3-none-win_arm64.whl", hash = "sha256:81fce74bcd6be0e9da7c67c7553fd0f1f12964a2052cd728ef917b937a5ca1d9", size = 9854332, upload-time = "2026-10-27T00:34:03.847Z" },
]
[[package]]
name = "sortedcontainers"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
]
[[package]]
name = "textual"
version = "8.2.8"