diff --git a/.githooks/commit-msg b/.githooks/commit-msg
new file mode 100755
index 0000000..b6f78a8
--- /dev/null
+++ b/.githooks/commit-msg
@@ -0,0 +1,21 @@
+#!/usr/bin/env sh
+set -eu
+
+msg_file="$1"
+
+first_line=$(sed -n '1p' "$msg_file")
+
+case "$first_line" in
+ Merge\ *|Revert\ *)
+ exit 0
+ ;;
+esac
+
+if ! printf '%s\n' "$first_line" | grep -Eq '^(feat|fix|docs|refactor|perf|test|chore|build|ci|style)(\([A-Za-z0-9_.-]+\))?!?: .+'; then
+ cat >&2 <<'EOF'
+commit-msg: expected a conventional commit subject.
+Example: fix: handle missing Docker socket gracefully
+Allowed types: feat, fix, docs, refactor, perf, test, chore, build, ci, style
+EOF
+ exit 1
+fi
diff --git a/.githooks/pre-push b/.githooks/pre-push
new file mode 100755
index 0000000..828f07c
--- /dev/null
+++ b/.githooks/pre-push
@@ -0,0 +1,7 @@
+#!/usr/bin/env sh
+set -eu
+
+uv run --locked python scripts/check_version_sync.py
+uv run --locked python scripts/check_publishing_ready.py
+uv run --locked python scripts/generate_cli_skills.py --check
+git diff --check
diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md
index 9d633d3..4541d89 100644
--- a/.github/CODE_OF_CONDUCT.md
+++ b/.github/CODE_OF_CONDUCT.md
@@ -48,7 +48,7 @@ This Code of Conduct is adapted from the [Contributor Covenant](https://www.cont
Slavic Kozyuk
-© 2026 Crux Experts LLC — MIT License
+© 2026 Crux Experts LLC — MIT License
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index d119b09..2aebcb2 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -6,23 +6,29 @@ Thanks for taking the time to contribute. This document covers how to set up a d
## Development setup
-For contributing you need an editable install so source changes take effect immediately. Clone the repo and use a virtual environment (pipx does not support editable mode):
+For contributing you need a UV-managed editable environment so source changes take effect immediately:
```bash
-git clone https://github.com/cptnfren/best-backup.git
+git clone https://github.com/CruxExperts/best-backup.git
cd best-backup
-python3 -m venv .venv
-source .venv/bin/activate
-pip install -e .
+uv sync --locked
# Verify
-bbackup --version
-bbman --version
+uv run bbackup --version
+uv run bbman --version
```
You will need Docker running locally to test backup and restore operations. `rsync` is required for volume backups; install it with your system package manager if it is not already present.
+Enable the repo-managed Git hooks once per checkout:
+
+```bash
+git config core.hooksPath .githooks
+```
+
+The hooks validate conventional commit subjects and run release-readiness checks before push. See [docs/VERSIONING.md](../docs/VERSIONING.md) for the full version and release checklist.
+
---
## Making changes
@@ -32,7 +38,7 @@ Keep changes focused. A pull request that fixes one bug or adds one feature is e
Run the syntax check before pushing:
```bash
-python3 -m py_compile bbackup/*.py bbackup/management/*.py
+uv run python -m py_compile bbackup.py bbman.py bbackup/*.py bbackup/data/*.py bbackup/management/*.py scripts/*.py
```
---
@@ -89,7 +95,7 @@ This project follows the [Contributor Covenant](CODE_OF_CONDUCT.md). Treat every
Slavic Kozyuk
-© 2026 Crux Experts LLC — MIT License
+© 2026 Crux Experts LLC — MIT License
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index 84b975e..b71fc0b 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -27,10 +27,10 @@ $ bbackup ...
## Environment
- OS and version:
-- Python version (`python3 --version`):
+- Python version (`uv run python --version` or `python3 --version`):
- Docker version (`docker --version`):
- bbackup version (`bbackup --version`):
-- Installation method (pip install / symlink / PATH):
+- Installation method (uv tool / uv sync / symlink / PATH):
## Configuration
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index 364131f..87b1538 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -16,7 +16,7 @@
## Checklist
-- [ ] Code runs without syntax errors (`python3 -m py_compile bbackup/*.py`)
+- [ ] Code runs without syntax errors (`uv run python -m py_compile bbackup.py bbman.py bbackup/*.py bbackup/data/*.py bbackup/management/*.py scripts/*.py`)
- [ ] Commit messages follow conventional commit format (`feat:`, `fix:`, `docs:`, etc.)
- [ ] Documentation updated if behavior changed
- [ ] No secrets, keys, or personal data included
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c8cb9ad..051cff6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,6 +6,9 @@ on:
pull_request:
branches: [main]
+permissions:
+ contents: read
+
jobs:
lint-and-check:
name: Lint and syntax check (Python ${{ matrix.python-version }})
@@ -13,37 +16,56 @@ jobs:
strategy:
fail-fast: false
matrix:
- python-version: ["3.10", "3.11", "3.12"]
+ python-version: ["3.12", "3.13"]
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
+ - name: Install uv
+ run: python -m pip install uv
+
- name: Install dependencies
- run: |
- python -m pip install --upgrade pip
- pip install ruff
- pip install -e .
+ run: uv sync --locked
- name: Lint with ruff
- run: ruff check bbackup/
+ run: uv run ruff check bbackup/
- name: Check syntax (py_compile)
run: |
- python -m py_compile bbackup/*.py
- python -m py_compile bbackup/management/*.py
+ uv run python -m py_compile bbackup.py bbman.py
+ uv run python -m py_compile bbackup/*.py
+ uv run python -m py_compile bbackup/data/*.py
+ uv run python -m py_compile bbackup/management/*.py
+ uv run python -m py_compile scripts/*.py
- name: Check imports
run: |
- python -c "from bbackup.config import Config; print('config OK')"
- python -c "from bbackup.cli import cli; print('cli OK')"
- python -c "from bbackup.encryption import EncryptionManager; print('encryption OK')"
- python -c "from bbackup.remote import RemoteStorage; print('remote OK')"
- python -c "from bbackup.rotation import BackupRotation; print('rotation OK')"
+ uv run python -c "from bbackup.config import Config; print('config OK')"
+ uv run python -c "from bbackup.cli import cli; print('cli OK')"
+ uv run python -c "from bbackup.encryption import EncryptionManager; print('encryption OK')"
+ uv run python -c "from bbackup.remote import RemoteStorage; print('remote OK')"
+ uv run python -c "from bbackup.rotation import BackupRotation; print('rotation OK')"
+
+ - name: Verify version references are in sync
+ run: uv run python scripts/check_version_sync.py
+
+ - name: Verify publishing checklist is present
+ run: |
+ test -f docs/PUBLISHING_CHECKLIST.md
+ test -f SUPPORT.md
+
+ - name: Verify publishing readiness
+ run: uv run python scripts/check_publishing_ready.py
+
+ - name: Verify installed artifact smoke test
+ run: |
+ uv build
+ uv run python scripts/smoke_installed_artifact.py
test:
name: Unit tests (Python ${{ matrix.python-version }})
@@ -52,31 +74,33 @@ jobs:
strategy:
fail-fast: false
matrix:
- python-version: ["3.10", "3.11", "3.12"]
+ python-version: ["3.12", "3.13"]
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
+ - name: Install uv
+ run: python -m pip install uv
+
- name: Install dependencies
- run: pip install -r requirements.txt -r requirements-dev.txt && pip install -e .
+ run: uv sync --locked
- name: Run unit tests with coverage
- run: pytest tests/ -m "not integration" --cov=bbackup --cov-report=xml --cov-report=term-missing
+ run: uv run pytest tests/ -m "not integration" --cov=bbackup --cov-report=xml --cov-report=term-missing
- name: Upload coverage artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v7
with:
name: coverage-${{ matrix.python-version }}
path: coverage.xml
- name: Verify CLI skills docs are up to date
- run: |
- python scripts/generate_cli_skills.py --check
+ run: uv run python scripts/generate_cli_skills.py --check
integration-test:
name: Integration tests
@@ -84,15 +108,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: "3.12"
+ - name: Install uv
+ run: python -m pip install uv
+
- name: Install dependencies
- run: pip install -r requirements.txt -r requirements-dev.txt && pip install -e .
+ run: uv sync --locked
- name: Run integration tests
- run: pytest tests/integration/ -m integration -v --tb=short
+ run: uv run pytest tests/integration/ -m integration -v --tb=short
diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml
index 17c1430..cf298f9 100644
--- a/.github/workflows/release-notes.yml
+++ b/.github/workflows/release-notes.yml
@@ -13,14 +13,33 @@ jobs:
contents: write
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
+ - name: Set up Python
+ uses: actions/setup-python@v6
+ with:
+ python-version: "3.12"
+
+ - name: Install uv
+ run: python -m pip install uv
+
- name: Extract version from tag
id: version
run: echo "version=${GITHUB_REF#refs/tags/v}" >> "$GITHUB_OUTPUT"
+ - name: Verify release version and publishing readiness
+ run: |
+ test "${{ steps.version.outputs.version }}" = "$(cat VERSION)"
+ uv sync --locked
+ uv run python scripts/check_version_sync.py
+ uv run python scripts/check_publishing_ready.py
+ uv run ruff check bbackup/ scripts/check_publishing_ready.py scripts/check_version_sync.py scripts/smoke_installed_artifact.py
+ uv run python scripts/generate_cli_skills.py --check
+ uv run python -m py_compile bbackup.py bbman.py bbackup/*.py bbackup/data/*.py bbackup/management/*.py scripts/*.py
+ uv run pytest
+
- name: Extract changelog section for this version
id: changelog
run: |
@@ -28,15 +47,23 @@ jobs:
# Pull the block between the version header and the next version header
NOTES=$(awk "/^## \[$VERSION\]/{found=1; next} found && /^## \[/{exit} found{print}" CHANGELOG.md)
if [ -z "$NOTES" ]; then
- NOTES="See CHANGELOG.md for details."
+ echo "No CHANGELOG.md section found for v$VERSION" >&2
+ exit 1
fi
# Write to file to preserve newlines
echo "$NOTES" > release_notes.txt
+ - name: Build release artifacts
+ run: uv build
+
+ - name: Smoke test built wheel
+ run: uv run python scripts/smoke_installed_artifact.py
+
- name: Create GitHub release
- uses: softprops/action-gh-release@v2
+ uses: softprops/action-gh-release@v3
with:
name: "v${{ steps.version.outputs.version }}"
body_path: release_notes.txt
+ files: dist/*
draft: false
prerelease: false
diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml
index dde541c..b0866ee 100644
--- a/.github/workflows/stale.yml
+++ b/.github/workflows/stale.yml
@@ -5,11 +5,15 @@ on:
- cron: "0 9 * * 1" # Every Monday at 09:00 UTC
workflow_dispatch:
+permissions:
+ issues: write
+ pull-requests: write
+
jobs:
stale:
runs-on: ubuntu-latest
steps:
- - uses: actions/stale@v9
+ - uses: actions/stale@v10
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 61f248f..bebb933 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,8 +36,14 @@ ENV/
# Cursor (IDE-local, not tracked)
.cursor/
-# Localsetup framework (tooling, not part of this repo)
+# Localsetup and Codex runtime state (tooling, not part of this repo)
_localsetup/
+.localsetup/
+.codex/skills/
+.codex/runs/
+.codex/sessions/
+.codex/logs/
+.codex/tmp/
# Release and maintenance tooling (local only, not published)
maintenance/
diff --git a/.python-version b/.python-version
new file mode 100644
index 0000000..e4fba21
--- /dev/null
+++ b/.python-version
@@ -0,0 +1 @@
+3.12
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..ab3a290
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,78 @@
+# bbackup Agent Instructions
+
+This repo contains the source for `bbackup`, a Python backup utility and its
+maintenance tooling.
+
+## Operating Model
+
+- Treat `/mnt/data/devzone/bbackup` as the active development checkout.
+- Treat `origin` as `https://github.com/CruxExperts/best-backup.git`.
+- Keep repo policy in this file and durable user-facing docs under `docs/` or
+ root entry points such as `README.md`, `INSTALL.md`, and `QUICKSTART.md`.
+- Keep runtime state, logs, caches, and agent run ledgers out of Git.
+- Use Localsetup-native commands before editing Codex adapter state manually.
+
+## Scope Control
+
+- Make surgical changes that trace directly to the task.
+- Do not refactor unrelated backup, restore, encryption, Docker, or TUI code
+ while doing maintenance setup.
+- Do not commit secrets, keys, backup archives, local service logs, generated
+ runtime state, or machine-specific configuration.
+- Preserve the existing `.agent/` content unless the user explicitly asks to
+ migrate or remove it.
+
+## Localsetup
+
+This checkout uses a Localsetup-managed Codex skills adapter. Inspect it with:
+
+```bash
+localsetup adapters --target-directory /mnt/data/devzone/bbackup --platforms codex
+```
+
+Run the repo-level doctor with:
+
+```bash
+localsetup doctor --target-directory /mnt/data/devzone/bbackup --global-preset core --repo-preset core --platforms codex --dependency-mode uv-sync --json
+```
+
+If adapter shape needs to change, use `localsetup install`, `localsetup plan`,
+or `localsetup detach` before manual edits under `.codex/`.
+
+## Development Commands
+
+```bash
+git config core.hooksPath .githooks
+```
+
+```bash
+uv sync --locked
+```
+
+```bash
+uv run python scripts/check_version_sync.py
+```
+
+```bash
+uv run python scripts/check_publishing_ready.py
+```
+
+```bash
+uv run python -m py_compile bbackup.py bbman.py bbackup/*.py bbackup/data/*.py bbackup/management/*.py scripts/*.py
+```
+
+```bash
+uv run pytest
+```
+
+```bash
+git diff --check
+```
+
+## Documentation
+
+- Update docs in the same task when behavior, commands, install steps, or
+ maintenance workflow changes.
+- Prefer updating existing docs over creating new documents.
+- Keep command examples copy-ready and avoid documenting machine-specific
+ secrets or private paths unless they are explicitly local runtime examples.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1180a3a..8ac2a28 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,12 +2,43 @@
All notable changes to this project will be documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). This project uses [semantic versioning](https://semver.org/).
+Historical entries preserve the install and packaging guidance that was current
+for that release. Use `README.md`, `INSTALL.md`, and `docs/VERSIONING.md` for
+current uv-based install, development, and release instructions.
+
---
## [Unreleased]
---
+## [1.8.0] - 2026-06-09
+
+### Added
+
+- `backup_manifest.json` generation for non-cancelled backups, including schema version, source scope, filesystem source paths, volume artifacts, item results, errors, file sizes, and SHA-256 hashes.
+- Restore-time manifest verification that fails before mutation when files are missing, changed, unlisted, or outside the backup root.
+- Temp-to-final promotion for local, SFTP, rclone, and solid-archive writes so partial uploads are not exposed as completed backups.
+- GitHub-facing documentation graphics and Mermaid diagrams for the backup pipeline and release/readiness flow.
+
+### Changed
+
+- Successful encrypted backups now remove plaintext staging and report encryption based on actual output state.
+- Rclone remote listing now uses top-level `lsf` output so retention targets backup directories and archive files instead of individual nested files.
+- Existing Docker volume restore uses a staging-volume copy preflight before removing the original volume.
+- Direct `--paths` and configured filesystem sets now reject duplicate target names that would overwrite each other.
+- `init-encryption` now rejects unsupported generated-key passwords before creating key directories.
+- Generated asymmetric keys are RSA-4096 only; ECDSA is no longer advertised as a backup encryption option.
+
+### Fixed
+
+- Docker volume backup and restore now propagate failed `docker cp` return codes.
+- Failed Docker volume backup attempts remove incomplete local volume artifacts.
+- Solid archive failures preserve an existing final archive and remove partial files.
+- Partial backup JSON no longer reports encrypted output when encryption was skipped due to item failures.
+
+---
+
## [1.7.0] - 2026-03-06
### Added
@@ -189,18 +220,19 @@ All notable changes to this project will be documented here. Format follows [Kee
---
-[Unreleased]: https://github.com/cptnfren/best-backup/compare/v1.7.0...HEAD
-[1.7.0]: https://github.com/cptnfren/best-backup/compare/v1.6.0...v1.7.0
-[1.6.0]: https://github.com/cptnfren/best-backup/compare/v1.5.0...v1.6.0
-[1.5.0]: https://github.com/cptnfren/best-backup/compare/v1.4.0...v1.5.0
-[1.4.0]: https://github.com/cptnfren/best-backup/compare/v1.3.3...v1.4.0
-[1.3.3]: https://github.com/cptnfren/best-backup/compare/v1.3.2...v1.3.3
-[1.3.2]: https://github.com/cptnfren/best-backup/compare/v1.3.1...v1.3.2
-[1.3.1]: https://github.com/cptnfren/best-backup/compare/v1.3.0...v1.3.1
-[1.3.0]: https://github.com/cptnfren/best-backup/compare/v1.2.1...v1.3.0
-[1.2.1]: https://github.com/cptnfren/best-backup/compare/v1.2.0...v1.2.1
-[1.2.0]: https://github.com/cptnfren/best-backup/compare/v1.1.0...v1.2.0
-[1.1.0]: https://github.com/cptnfren/best-backup/releases/tag/v1.1.0
+[Unreleased]: https://github.com/CruxExperts/best-backup/compare/v1.8.0...HEAD
+[1.8.0]: https://github.com/CruxExperts/best-backup/compare/v1.7.0...v1.8.0
+[1.7.0]: https://github.com/CruxExperts/best-backup/compare/v1.6.0...v1.7.0
+[1.6.0]: https://github.com/CruxExperts/best-backup/compare/v1.5.0...v1.6.0
+[1.5.0]: https://github.com/CruxExperts/best-backup/compare/v1.4.0...v1.5.0
+[1.4.0]: https://github.com/CruxExperts/best-backup/compare/v1.3.3...v1.4.0
+[1.3.3]: https://github.com/CruxExperts/best-backup/compare/v1.3.2...v1.3.3
+[1.3.2]: https://github.com/CruxExperts/best-backup/compare/v1.3.1...v1.3.2
+[1.3.1]: https://github.com/CruxExperts/best-backup/compare/v1.3.0...v1.3.1
+[1.3.0]: https://github.com/CruxExperts/best-backup/compare/v1.2.1...v1.3.0
+[1.2.1]: https://github.com/CruxExperts/best-backup/compare/v1.2.0...v1.2.1
+[1.2.0]: https://github.com/CruxExperts/best-backup/compare/v1.1.0...v1.2.0
+[1.1.0]: https://github.com/CruxExperts/best-backup/releases/tag/v1.1.0
@@ -208,7 +240,7 @@ All notable changes to this project will be documented here. Format follows [Kee
Slavic Kozyuk
-© 2026 Crux Experts LLC — MIT License
+© 2026 Crux Experts LLC — MIT License
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..bfade75
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,6 @@
+# Contributing
+
+Contribution guidance lives in [.github/CONTRIBUTING.md](.github/CONTRIBUTING.md)
+so GitHub can surface it automatically in issues and pull requests.
+
+For local release and hook setup, see [docs/VERSIONING.md](docs/VERSIONING.md).
diff --git a/Dockerfile.test b/Dockerfile.test
index 4fbde64..a39bd88 100644
--- a/Dockerfile.test
+++ b/Dockerfile.test
@@ -1,12 +1,12 @@
FROM python:3.12-slim
RUN apt-get update \
- && apt-get install -y rsync docker.io \
+ && apt-get install -y curl rsync docker.io \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
-COPY requirements.txt requirements-dev.txt ./
-RUN pip install --no-cache-dir -r requirements.txt -r requirements-dev.txt
+COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
+COPY pyproject.toml uv.lock ./
+RUN uv sync --locked --no-install-project
COPY bbackup/ bbackup/
-COPY bbackup.py setup.py ./
-RUN pip install -e .
+COPY bbackup.py bbman.py ./
+RUN uv sync --locked
COPY tests/ tests/
-COPY pytest.ini ./
diff --git a/INSTALL.md b/INSTALL.md
index 238a32e..31d367c 100644
--- a/INSTALL.md
+++ b/INSTALL.md
@@ -4,131 +4,131 @@
---
-## Recommended: pipx, single user
+## Recommended: uv tool, single user
-`pipx` installs bbackup into an isolated virtual environment and wires `bbackup` and `bbman` into your PATH. You never have to activate anything.
+`uv tool install` installs bbackup into an isolated tool environment and links
+`bbackup` and `bbman` into the uv tool bin directory.
-### Install (single user)
+### Install uv
```bash
-# Install pipx (Ubuntu/Debian)
-sudo apt install pipx
-pipx ensurepath # adds ~/.local/bin to PATH — one-time setup
+curl -LsSf https://astral.sh/uv/install.sh | sh
+uv tool update-shell
+```
+
+Open a new shell after `uv tool update-shell`, then install bbackup:
-# Install bbackup into a dedicated venv and add it to PATH
-pipx install git+https://github.com/cptnfren/best-backup.git
+```bash
+uv tool install git+https://github.com/CruxExperts/best-backup.git
# Verify
bbackup --version
bbman --version
```
-### Update (single user)
+### Update
```bash
-pipx upgrade bbackup
-```
-
-If you run `pipx install ...` again after `bbackup` is already installed, pipx prints a message like:
-
-```text
-'bbackup' already seems to be installed. Not modifying existing installation. Pass '--force' to force installation.
+uv tool upgrade bbackup
```
-This is expected. Use `pipx upgrade bbackup` to pull a newer version, or `pipx reinstall bbackup` if you want a fresh virtual environment.
+Use `uv tool install --force git+https://github.com/CruxExperts/best-backup.git`
+if you want a fresh tool environment.
-### Uninstall (single user)
+### Uninstall
```bash
-pipx uninstall bbackup
+uv tool uninstall bbackup
```
---
-## Server install: pipx, system-wide (all users)
-
-The single-user method above installs only for the user who ran it. On a shared server, or when cron jobs run as root or another user, use the system-wide approach instead. It places `bbackup` and `bbman` in `/usr/local/bin`, which is on every user's PATH by default.
+## Server install: uv tool, system-wide
-### Install (server / all users)
+For shared servers or cron jobs that need commands under `/usr/local/bin`, run
+uv with explicit tool directories:
```bash
-sudo apt install pipx
-sudo PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install git+https://github.com/cptnfren/best-backup.git
+curl -LsSf https://astral.sh/uv/install.sh | sh
+sudo env UV_TOOL_DIR=/opt/uv/tools UV_TOOL_BIN_DIR=/usr/local/bin uv tool install git+https://github.com/CruxExperts/best-backup.git
# Verify as any user
bbackup --version
bbman --version
```
-### Update (server / all users)
-
-```bash
-sudo PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx upgrade bbackup
-```
-
-### Uninstall (server / all users)
+Update or uninstall with the same directory environment:
```bash
-sudo PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx uninstall bbackup
+sudo env UV_TOOL_DIR=/opt/uv/tools UV_TOOL_BIN_DIR=/usr/local/bin uv tool upgrade bbackup
+sudo env UV_TOOL_DIR=/opt/uv/tools UV_TOOL_BIN_DIR=/usr/local/bin uv tool uninstall bbackup
```
-Note: `pipx ensurepath` is not needed for the system-wide method since `/usr/local/bin` is already on every user's PATH.
-
-Each user still has their own config at `~/.config/bbackup/config.yaml` and their own log at `~/.local/share/bbackup/bbackup.log`. Only the binary is shared.
+Each user still has their own config at `~/.config/bbackup/config.yaml` and
+their own log at `~/.local/share/bbackup/bbackup.log`. Only the command links
+are shared.
---
-## Manual virtual environment install (development / editable mode)
+## Development setup
-Use this if you want to edit the source code and have changes take effect immediately without reinstalling.
+Use this if you want to edit the source code and have changes take effect
+immediately.
```bash
-git clone https://github.com/cptnfren/best-backup.git
+git clone https://github.com/CruxExperts/best-backup.git
cd best-backup
-python3 -m venv .venv
-source .venv/bin/activate
-pip install -e .
+uv sync --locked
+uv run bbackup --version
+uv run bbman --version
```
-To make the commands available in every new shell without activating the venv each time:
+The project requires Python 3.12 or newer. The repo includes `.python-version`
+with `3.12`; uv will create `.venv` automatically when you run `uv sync --locked`.
+
+To run commands through the development environment:
```bash
-echo 'export PATH="$HOME/best-backup/.venv/bin:$PATH"' >> ~/.bashrc
-source ~/.bashrc
+uv run bbackup backup --help
+uv run bbman setup --help
```
---
-## Production install (stable, from local clone, no editable mode)
+## Production install from a local clone
+
+```bash
+cd /path/to/best-backup
+uv tool install .
+```
+
+For an editable local tool install:
```bash
-python3 -m venv ~/.venvs/bbackup
-source ~/.venvs/bbackup/bin/activate
-pip install /path/to/best-backup
+uv tool install --editable .
```
---
## Symlinks (no install, quick)
-If you want to run from the repo directory without `pip`:
+If you want to run from the repo directory without installing the package:
```bash
chmod +x bbackup.py bbman.py
-sudo ln -s $(pwd)/bbackup.py /usr/local/bin/bbackup
-sudo ln -s $(pwd)/bbman.py /usr/local/bin/bbman
+sudo ln -s "$(pwd)/bbackup.py" /usr/local/bin/bbackup
+sudo ln -s "$(pwd)/bbman.py" /usr/local/bin/bbman
```
For a user-only version without `sudo`:
```bash
mkdir -p ~/bin
-ln -s $(pwd)/bbackup.py ~/bin/bbackup
-ln -s $(pwd)/bbman.py ~/bin/bbman
+ln -s "$(pwd)/bbackup.py" ~/bin/bbackup
+ln -s "$(pwd)/bbman.py" ~/bin/bbman
-# Add ~/bin to PATH if not already there
export PATH="$HOME/bin:$PATH"
```
@@ -146,53 +146,18 @@ Add that export line to your shell profile to make it permanent.
---
-## Uninstall
-
-If installed via pipx (single user):
-
-```bash
-pipx uninstall bbackup
-```
-
-If installed via pipx (system-wide):
-
-```bash
-sudo PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx uninstall bbackup
-```
-
-If installed into a manual venv:
-
-```bash
-source .venv/bin/activate
-pip uninstall bbackup
-# or remove the whole venv:
-rm -rf .venv
-```
-
-If installed via symlinks:
-
-```bash
-sudo rm /usr/local/bin/bbackup /usr/local/bin/bbman
-# or for user symlinks:
-rm ~/bin/bbackup ~/bin/bbman
-```
-
----
-
## Python version
-Python 3.10+ is required. Check with:
+Python 3.12+ is required. Check the project interpreter with:
```bash
-python3 --version
+uv run python --version
```
-If you have multiple Python versions and need to target a specific one:
+If you need uv to create the environment with a specific interpreter:
```bash
-python3.10 -m venv .venv
-source .venv/bin/activate
-pip install -e .
+uv sync --locked --python 3.12
```
---
@@ -201,49 +166,42 @@ pip install -e .
**`bbackup: command not found` after install**
-If you installed into a venv, make sure the venv is active (or its `bin/` directory is on your PATH):
+Make sure the uv tool bin directory is on your PATH:
```bash
-source .venv/bin/activate
-which bbackup # should now resolve
+uv tool dir --bin
+uv tool update-shell
```
-Alternatively, add the venv bin dir to your shell profile permanently:
-
-```bash
-echo 'export PATH="$HOME/best-backup/.venv/bin:$PATH"' >> ~/.bashrc
-source ~/.bashrc
-```
+Open a new shell after updating the shell configuration.
**Permission denied**
-Use a virtual environment (no sudo required):
+Use the single-user install unless you intentionally need system-wide commands:
```bash
-python3 -m venv .venv
-source .venv/bin/activate
-pip install -e .
+uv tool install git+https://github.com/CruxExperts/best-backup.git
```
**`error: externally-managed-environment` on Ubuntu 22.04+ / Debian 12+**
-These distros block `pip install` on the system Python (PEP 668). The fix is `pipx`, which handles isolation automatically:
+Do not install into the system Python. Use uv tool installs or the UV-managed
+project environment:
```bash
-sudo apt install pipx && pipx ensurepath
-pipx install git+https://github.com/cptnfren/best-backup.git
+uv tool install git+https://github.com/CruxExperts/best-backup.git
+uv sync --locked
```
-Do not pass `--break-system-packages`; that flag bypasses OS safeguards and can corrupt tools that depend on the system Python.
+Do not pass `--break-system-packages`; that flag bypasses OS safeguards and can
+corrupt tools that depend on the system Python.
-**Packages fail to install**
+**Dependencies look stale**
-Make sure pip is up to date inside your venv:
+Refresh the project environment from the lockfile:
```bash
-source .venv/bin/activate
-pip install --upgrade pip
-pip install -e .
+uv sync --locked
```
---
@@ -264,7 +222,7 @@ See [QUICKSTART.md](QUICKSTART.md) for what to do next.
Slavic Kozyuk
-© 2026 Crux Experts LLC — MIT License
+© 2026 Crux Experts LLC — MIT License
diff --git a/QUICKSTART.md b/QUICKSTART.md
index 28b1fd0..3ccff0e 100644
--- a/QUICKSTART.md
+++ b/QUICKSTART.md
@@ -6,29 +6,29 @@
## Step 1: Install
-`pipx` handles the virtual environment automatically and wires `bbackup` and `bbman` into your PATH.
+`uv` handles the isolated tool environment automatically and wires `bbackup` and `bbman` into your PATH.
**Single user** (commands available only to you):
```bash
-sudo apt install pipx
-pipx ensurepath
+curl -LsSf https://astral.sh/uv/install.sh | sh
+uv tool update-shell
```
Open a new shell, then:
```bash
-pipx install git+https://github.com/cptnfren/best-backup.git
+uv tool install git+https://github.com/CruxExperts/best-backup.git
```
**Server / all users** (commands available to every user and cron jobs):
```bash
-sudo apt install pipx
-sudo PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install git+https://github.com/cptnfren/best-backup.git
+curl -LsSf https://astral.sh/uv/install.sh | sh
+sudo env UV_TOOL_DIR=/opt/uv/tools UV_TOOL_BIN_DIR=/usr/local/bin uv tool install git+https://github.com/CruxExperts/best-backup.git
```
-If you already have `bbackup` installed via `pipx` and only need to update it, run `pipx upgrade bbackup` for a single-user install or `sudo PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx upgrade bbackup` for the server-wide method, rather than re-running `pipx install`. See [INSTALL.md](INSTALL.md) for uninstall and alternative install methods.
+If you already have `bbackup` installed via `uv` and only need to update it, run `uv tool upgrade bbackup` for a single-user install or `sudo env UV_TOOL_DIR=/opt/uv/tools UV_TOOL_BIN_DIR=/usr/local/bin uv tool upgrade bbackup` for the server-wide method, rather than re-running `uv tool install`. See [INSTALL.md](INSTALL.md) for uninstall and alternative install methods.
---
@@ -260,7 +260,7 @@ See [README.md](README.md#agent-integration) for the full agent integration refe
Slavic Kozyuk
-© 2026 Crux Experts LLC — MIT License
+© 2026 Crux Experts LLC — MIT License
diff --git a/README.md b/README.md
index 7cb59ec..6626ead 100644
--- a/README.md
+++ b/README.md
@@ -4,30 +4,53 @@
**Back up Docker containers and host filesystems — encrypted, incremental, and agent-ready.**
-[](https://www.python.org/downloads/)
+[](https://www.python.org/downloads/)
[](LICENSE)
-[](CHANGELOG.md)
+[](CHANGELOG.md)
[Quick start](#quick-start) · [Filesystem backup](#filesystem-backup) · [Agent integration](#agent-integration) · [CLI reference](#cli-reference) · [Docs](#documentation)
+
+
---
```bash
-pipx install git+https://github.com/cptnfren/best-backup.git
+uv tool install git+https://github.com/CruxExperts/best-backup.git
```
-`pipx` handles the virtual environment automatically. If `bbackup` is already installed and you want to move to a newer version, use `pipx upgrade bbackup` (or the system-wide variant from [Installation](#installation)) instead of re-running `pipx install`. See [Installation](#installation) if you need to install `pipx` first, or for alternative methods.
+`uv` handles the isolated tool environment automatically. If `bbackup` is already installed and you want to move to a newer version, use `uv tool upgrade bbackup` (or the system-wide variant from [Installation](#installation)) instead of re-running `uv tool install`. See [Installation](#installation) if you need to install `uv` first, or for alternative methods.
---
-## What it does
+## Why bbackup
-Run `bbackup backup` and you get an interactive container picker, a live BTOP-style dashboard while the backup runs, and a finished archive that can be encrypted and shipped to Google Drive, SFTP, or a local path. Point it at `/srv/data` and it backs that up too, with gitignore-style excludes. The companion `bbman` command handles setup, health checks, dependency installs, and self-updates so day-to-day maintenance stays out of the way.
+Run `bbackup backup` and you get an interactive container picker, a live BTOP-style dashboard while the backup runs, and a finished artifact that can be verified, encrypted, and shipped to Google Drive, SFTP, or a local path. Point it at `/srv/data` and it backs that up too, with gitignore-style excludes. The companion `bbman` command handles setup, health checks, dependency installs, and self-updates so day-to-day maintenance stays out of the way.
Every command speaks structured JSON, making it compatible with AI agents out of the box: set two env vars, run `bbackup skills`, and drive the entire tool with `--input-json`.
+> [!TIP]
+> Use `--dry-run --output json` before destructive restore work or scheduled backup changes. The JSON plan is designed for both humans and automation.
+
+## Backup flow
+
+```mermaid
+flowchart LR
+ docker[Docker containers
volumes • configs • networks]
+ fs[Host filesystems
paths • excludes]
+ manifest[backup_manifest.json
sizes • SHA-256 • item results]
+ encrypt[Encryption
AES-256-GCM or RSA-4096]
+ upload[Remote upload
local • SFTP • rclone]
+ restore[Restore preflight
manifest verification]
+
+ docker --> manifest
+ fs --> manifest
+ manifest --> encrypt
+ encrypt --> upload
+ manifest --> restore
+```
+
---
## Features
@@ -51,7 +74,7 @@ Every command speaks structured JSON, making it compatible with AI agents out of
## Requirements
-- Python 3.10+
+- Python 3.12+
- Docker (with socket access for your user)
- `rsync` (system package — used for volume and filesystem backups)
- `rclone` (optional, for Google Drive)
@@ -60,47 +83,48 @@ Every command speaks structured JSON, making it compatible with AI agents out of
## Installation
-`pipx` handles the virtual environment automatically. Pick the method that fits your setup.
+`uv` handles the tool environment automatically. Pick the method that fits your setup.
-### pipx install (single user)
+### uv tool install (single user)
Use this when installing `bbackup` for the first time for your user only:
```bash
-sudo apt install pipx && pipx ensurepath
-pipx install git+https://github.com/cptnfren/best-backup.git
+curl -LsSf https://astral.sh/uv/install.sh | sh
+uv tool update-shell
+uv tool install git+https://github.com/CruxExperts/best-backup.git
```
Open a new shell and both commands are ready.
-### pipx upgrade (single user)
+### uv tool upgrade (single user)
-If `bbackup` is already installed via pipx and you just want to move to a newer version:
+If `bbackup` is already installed via uv and you just want to move to a newer version:
```bash
-pipx upgrade bbackup
+uv tool upgrade bbackup
```
-Use `pipx reinstall bbackup` if you want a fresh virtual environment.
+Use `uv tool install --force git+https://github.com/CruxExperts/best-backup.git` if you want a fresh tool environment.
-### pipx install (server / all users)
+### uv tool install (server / all users)
Installs to `/usr/local/bin` and makes `bbackup` and `bbman` available to every user and cron job:
```bash
-sudo apt install pipx
-sudo PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx install git+https://github.com/cptnfren/best-backup.git
+curl -LsSf https://astral.sh/uv/install.sh | sh
+sudo env UV_TOOL_DIR=/opt/uv/tools UV_TOOL_BIN_DIR=/usr/local/bin uv tool install git+https://github.com/CruxExperts/best-backup.git
```
-### pipx upgrade (server / all users)
+### uv tool upgrade (server / all users)
-If `bbackup` is already installed system-wide via pipx and you want to update in place:
+If `bbackup` is already installed system-wide via uv and you want to update in place:
```bash
-sudo PIPX_HOME=/opt/pipx PIPX_BIN_DIR=/usr/local/bin pipx upgrade bbackup
+sudo env UV_TOOL_DIR=/opt/uv/tools UV_TOOL_BIN_DIR=/usr/local/bin uv tool upgrade bbackup
```
-For development installs, manual venv setup, and uninstall instructions, see [INSTALL.md](INSTALL.md).
+For development setup, local source installs, and uninstall instructions, see [INSTALL.md](INSTALL.md).
---
@@ -127,6 +151,14 @@ See [QUICKSTART.md](QUICKSTART.md) for a full walk-through: config, remote stora
---
+## Integrity And Upload Safety
+
+Each non-cancelled backup writes `backup_manifest.json` with the requested scope, filesystem source paths, volume artifact names, item results, errors, file sizes, and SHA-256 hashes. Restore verifies the manifest when present and fails before mutation if files are missing, changed, unlisted, or outside the backup root.
+
+Local, SFTP, and rclone uploads write to `.partial` destinations first and promote to the final backup name only after the copy succeeds. When encryption succeeds, plaintext staging is removed so local backup artifacts match the encrypted output.
+
+---
+
## Configuration
bbackup checks these locations in order:
@@ -301,7 +333,7 @@ Level-0 JSON output:
```json
{
"cli": "bbackup",
- "version": "1.4.0",
+ "version": "1.8.0",
"agent_hint": "Set BBACKUP_OUTPUT=json and BBACKUP_NO_INTERACTIVE=1 for fully non-interactive use.",
"skills": [
{"id": "docker-backup", "summary": "Back up Docker containers, volumes, networks, and configs.", "common": true},
@@ -447,8 +479,8 @@ best-backup/
├── bbackup.py # bbackup entry point
├── bbman.py # bbman entry point
├── config.yaml.example # Annotated config template
-├── requirements.txt
-└── setup.py
+├── pyproject.toml
+└── uv.lock
```
---
@@ -462,9 +494,12 @@ best-backup/
| [docs/management.md](docs/management.md) | Full `bbman` reference |
| [docs/encryption.md](docs/encryption.md) | Encryption setup and key management |
| [CHANGELOG.md](CHANGELOG.md) | Release history |
-| [CONTRIBUTING.md](.github/CONTRIBUTING.md) | How to contribute |
+| [CONTRIBUTING.md](CONTRIBUTING.md) | How to contribute |
| [SECURITY.md](SECURITY.md) | How to report vulnerabilities |
+| [SUPPORT.md](SUPPORT.md) | Where to ask questions or get help |
| [docs/cli-skills.md](docs/cli-skills.md) | Unified CLI skills catalog for humans and AI agents |
+| [docs/VERSIONING.md](docs/VERSIONING.md) | Version source of truth, hook setup, and release validation |
+| [docs/PUBLISHING_CHECKLIST.md](docs/PUBLISHING_CHECKLIST.md) | GitHub publishing and release readiness checklist |
---
@@ -481,10 +516,11 @@ best-backup/
- [x] Management wrapper (`bbman`)
- [x] GitHub key integration for public key distribution
- [x] AI agent JSON I/O, skill discovery, `--dry-run`, and `--input-json` on all commands
+- [x] Backup manifest verification with SHA-256 hashes
+- [x] Temp-to-final upload promotion for local, SFTP, and rclone remotes
**Planned**
-- [ ] Backup verification and checksums
- [ ] Email and webhook notifications
- [ ] Cron-based scheduling integration
- [ ] Multi-server backup coordination
@@ -509,7 +545,7 @@ Built with [Rich](https://github.com/Textualize/rich), [Click](https://github.co
Slavic Kozyuk
-© 2026 Crux Experts LLC — MIT License
+© 2026 Crux Experts LLC — MIT License
diff --git a/SECURITY.md b/SECURITY.md
index 53a1868..726afc3 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -21,7 +21,7 @@ Please do not open a public GitHub issue for security vulnerabilities.
Use GitHub's private vulnerability reporting instead:
-1. Go to the [Security tab](https://github.com/cptnfren/best-backup/security) of this repository.
+1. Go to the [Security tab](https://github.com/CruxExperts/best-backup/security) of this repository.
2. Click "Report a vulnerability."
3. Describe the issue, steps to reproduce, and potential impact.
@@ -51,7 +51,7 @@ Back to [README.md](README.md).
Slavic Kozyuk
-© 2026 Crux Experts LLC — MIT License
+© 2026 Crux Experts LLC — MIT License
diff --git a/SUPPORT.md b/SUPPORT.md
new file mode 100644
index 0000000..2edcd81
--- /dev/null
+++ b/SUPPORT.md
@@ -0,0 +1,10 @@
+# Support
+
+Use GitHub Issues for bug reports and feature requests:
+
+```text
+https://github.com/CruxExperts/best-backup/issues
+```
+
+For security vulnerabilities, do not open a public issue. Follow
+[SECURITY.md](SECURITY.md) instead.
diff --git a/VERSION b/VERSION
index bd8bf88..27f9cd3 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.7.0
+1.8.0
diff --git a/bbackup/__init__.py b/bbackup/__init__.py
index c5a3f35..4b0a58f 100644
--- a/bbackup/__init__.py
+++ b/bbackup/__init__.py
@@ -3,5 +3,5 @@
A comprehensive backup solution for Docker containers, volumes, and configurations.
"""
-__version__ = "1.7.0"
+__version__ = "1.8.0"
__author__ = "Slavic Kozyuk / Crux Experts LLC"
diff --git a/bbackup/archive.py b/bbackup/archive.py
index 85ce51e..c1de464 100644
--- a/bbackup/archive.py
+++ b/bbackup/archive.py
@@ -7,7 +7,7 @@
"""
import gzip
-import shutil
+import os
import tarfile
import tempfile
from pathlib import Path
@@ -79,41 +79,49 @@ def create_solid_archive(
level = int(compression_config.get("level", 6))
ext = _compression_ext(fmt)
archive_path = backup_dir.parent / f"{backup_dir.name}.tar.{ext}"
- created_path: Optional[Path] = None
+ partial_archive_path = archive_path.with_name(f"{archive_path.name}.partial")
+ partial_enc_path: Optional[Path] = None
try:
+ if partial_archive_path.exists():
+ partial_archive_path.unlink()
if fmt == "gzip" and level != 9:
# Use gzip level (Gap 6)
- with open(archive_path, "wb") as f:
+ with open(partial_archive_path, "wb") as f:
with gzip.GzipFile(fileobj=f, mode="wb", compresslevel=level) as gz:
with tarfile.open(fileobj=gz, mode="w") as tar:
tar.add(backup_dir, arcname=backup_dir.name)
else:
# tarfile built-in compression (level not configurable for bz2/xz in same way)
mode = _tar_mode(fmt)
- with tarfile.open(archive_path, mode) as tar:
+ with tarfile.open(partial_archive_path, mode) as tar:
tar.add(backup_dir, arcname=backup_dir.name)
- created_path = archive_path
-
if encryption_config is not None and getattr(encryption_config, "enabled", False):
enc_path = archive_path.with_suffix(archive_path.suffix + ".enc")
+ partial_enc_path = enc_path.with_name(f"{enc_path.name}.partial")
+ if partial_enc_path.exists():
+ partial_enc_path.unlink()
mgr = EncryptionManager(encryption_config)
- if not mgr.encrypt_file(archive_path, enc_path):
+ if not mgr.encrypt_file(partial_archive_path, partial_enc_path):
raise OSError("Encryption of archive failed")
try:
- archive_path.unlink()
+ partial_archive_path.unlink()
except OSError as e:
logger.warning(f"Could not remove intermediate archive after encrypt: {e}")
+ os.replace(partial_enc_path, enc_path)
archive_path = enc_path
+ else:
+ os.replace(partial_archive_path, archive_path)
return archive_path
except Exception:
- if created_path is not None and created_path.exists():
- try:
- created_path.unlink()
- except OSError:
- pass
+ for path in (partial_archive_path, partial_enc_path):
+ if path is not None and path.exists():
+ try:
+ path.unlink()
+ except OSError:
+ pass
raise
diff --git a/bbackup/backup_runner.py b/bbackup/backup_runner.py
index 03b7b76..d777599 100644
--- a/bbackup/backup_runner.py
+++ b/bbackup/backup_runner.py
@@ -13,6 +13,7 @@
from .tui import BackupStatus
from .logging import get_logger
from .encryption import EncryptionManager
+from .manifest import generate_backup_manifest
logger = get_logger('backup_runner')
@@ -267,9 +268,27 @@ def run_backup(
if self.status.status == "cancelled":
self.status.status = "cancelled"
+ elif results["errors"]:
+ self.status.status = "partial"
else:
self.status.status = "completed"
+ if self.status.status != "cancelled":
+ encryption_mode = self.config.encryption.method if self.config.encryption.enabled else "disabled"
+ generate_backup_manifest(
+ backup_dir,
+ scope,
+ filesystem_targets=fs_targets,
+ encryption_mode=encryption_mode,
+ item_results={
+ "containers": results["containers"],
+ "volumes": results["volumes"],
+ "networks": results["networks"],
+ "filesystems": results["filesystems"],
+ },
+ errors=results["errors"],
+ )
+
# TODO: call self.docker_backup.create_metadata_archive(backup_dir) here
# to produce a compressed tar of configs/networks metadata.
# The method exists in docker_backup.py but is not yet wired into
@@ -331,7 +350,10 @@ def encrypt_backup_directory(self, backup_dir: Path) -> Path:
backup_dir: Backup directory to encrypt
Returns:
- Path to encrypted backup directory (or original if encryption disabled/failed)
+ Path to encrypted backup directory, or original if encryption is disabled.
+
+ Raises:
+ RuntimeError: If encryption is enabled but encryption fails.
"""
if not self.config.encryption.enabled:
return backup_dir
@@ -350,15 +372,17 @@ def encrypt_backup_directory(self, backup_dir: Path) -> Path:
self.status.encryption_status = "encrypted"
return encrypted_dir
else:
- logger.warning("Encryption failed, using unencrypted backup")
- self.status.add_warning("Encryption failed, backup is unencrypted")
self.status.encryption_status = "failed"
- return backup_dir
+ message = "Encryption failed"
+ self.status.add_error(message)
+ raise RuntimeError(message)
except Exception as e:
logger.error(f"Encryption error: {e}")
- self.status.add_error(f"Encryption failed: {e}")
+ message = f"Encryption failed: {e}"
+ if message not in self.status.errors:
+ self.status.add_error(message)
self.status.encryption_status = "failed"
- return backup_dir # Return original on error
+ raise RuntimeError(message) from e
def upload_to_remotes(
self,
diff --git a/bbackup/bbman.py b/bbackup/bbman.py
index fd6a01e..a6cc5f7 100755
--- a/bbackup/bbman.py
+++ b/bbackup/bbman.py
@@ -14,13 +14,13 @@
import sys
import subprocess
from pathlib import Path
-from typing import Optional
import click
from rich.console import Console
# Gap 12: import __version__ instead of hardcoding "1.0.0"
from bbackup import __version__
+from bbackup.resources import read_text_resource, resource_exists
from bbackup.cli_utils import (
output_option,
@@ -33,15 +33,15 @@
EXIT_USER_ERROR,
EXIT_CONFIG_ERROR,
EXIT_SYSTEM_ERROR,
- EXIT_PARTIAL,
EXIT_CANCELLED,
BBACKUP_NO_INTERACTIVE_ENV,
)
from bbackup.skills import get_skill
-SKILLS_DOC_PATH = Path(__file__).parent / "docs" / "cli-skills.md"
-SKILLS_INDEX_PATH = Path(__file__).parent / "docs" / "cli-skills-index.json"
+SKILLS_DOC_RESOURCE = "cli-skills.md"
+SKILLS_INDEX_RESOURCE = "cli-skills-index.json"
+CANONICAL_REPO_URL = "https://github.com/CruxExperts/best-backup"
# Default repository URL: auto-detected from git remote, then placeholder.
# Set BBACKUP_REPO_URL env var or run `bbman repo-url --url URL` to override.
@@ -56,9 +56,9 @@
if _git_result.returncode == 0:
DEFAULT_REPO_URL = _git_result.stdout.strip().replace(".git", "")
else:
- DEFAULT_REPO_URL = "https://github.com/YOUR_USERNAME/best-backup"
+ DEFAULT_REPO_URL = CANONICAL_REPO_URL
except Exception:
- DEFAULT_REPO_URL = "https://github.com/YOUR_USERNAME/best-backup"
+ DEFAULT_REPO_URL = CANONICAL_REPO_URL
sys.path.insert(0, str(Path(__file__).parent))
@@ -580,7 +580,7 @@ def update(ctx, branch, method, yes, skills, output, input_json):
sys.exit(EXIT_SUCCESS)
if output != "json":
- console.print(f"[yellow]Updates available:[/yellow]")
+ console.print("[yellow]Updates available:[/yellow]")
console.print(f" [dim]Changed: {len(update_info.get('changed', []))} files[/dim]")
console.print(f" [dim]New: {len(update_info.get('new', []))} files[/dim]")
console.print(f" [dim]Removed: {len(update_info.get('removed', []))} files[/dim]")
@@ -600,7 +600,7 @@ def update(ctx, branch, method, yes, skills, output, input_json):
render_output(result, output, "update", success=bool(result.get("success")))
if output != "json":
if result.get("success"):
- console.print(f"[green]Update completed successfully![/green]")
+ console.print("[green]Update completed successfully![/green]")
console.print(f"[dim]Files updated: {result.get('files_updated', 0)}[/dim]")
if result.get("backup_dir"):
console.print(f"[dim]Backup saved to: {result['backup_dir']}[/dim]")
@@ -773,12 +773,12 @@ def _print_command_skills(cli_name: str, command_name: str) -> None:
"""
Print the skills documentation section for a specific CLI command and exit.
"""
- if not SKILLS_DOC_PATH.exists() or not SKILLS_INDEX_PATH.exists():
+ if not resource_exists(SKILLS_DOC_RESOURCE) or not resource_exists(SKILLS_INDEX_RESOURCE):
console.print("[red]Skills documentation has not been generated yet.[/red]")
sys.exit(EXIT_SYSTEM_ERROR)
try:
- index = json.loads(SKILLS_INDEX_PATH.read_text(encoding="utf-8"))
+ index = json.loads(read_text_resource(SKILLS_INDEX_RESOURCE))
except Exception as exc:
console.print(f"[red]Failed to read skills index: {exc}[/red]")
sys.exit(EXIT_SYSTEM_ERROR)
@@ -789,7 +789,7 @@ def _print_command_skills(cli_name: str, command_name: str) -> None:
console.print(f"[red]No skills entry found for command {cmd_id}.[/red]")
sys.exit(EXIT_USER_ERROR)
- lines = SKILLS_DOC_PATH.read_text(encoding="utf-8").splitlines()
+ lines = read_text_resource(SKILLS_DOC_RESOURCE).splitlines()
start = max(int(meta.get("start", 1)) - 1, 0)
end = min(int(meta.get("end", len(lines))), len(lines))
section = "\n".join(lines[start:end]) + "\n"
@@ -801,10 +801,10 @@ def _print_skills_markdown() -> None:
"""
Print the full Markdown skills catalog to stdout and exit.
"""
- if not SKILLS_DOC_PATH.exists():
+ if not resource_exists(SKILLS_DOC_RESOURCE):
console.print("[red]Skills documentation has not been generated yet.[/red]")
sys.exit(EXIT_SYSTEM_ERROR)
- sys.stdout.write(SKILLS_DOC_PATH.read_text(encoding="utf-8"))
+ sys.stdout.write(read_text_resource(SKILLS_DOC_RESOURCE))
sys.exit(EXIT_SUCCESS)
diff --git a/bbackup/bbman_entry.py b/bbackup/bbman_entry.py
index 06b49aa..21bc617 100644
--- a/bbackup/bbman_entry.py
+++ b/bbackup/bbman_entry.py
@@ -1,6 +1,6 @@
"""
Entry point wrapper for bbman CLI.
-This allows bbman to be registered as a console script in setup.py.
+This allows bbman to be registered as a console script in pyproject.toml.
"""
# Import and re-export the cli function
diff --git a/bbackup/cli.py b/bbackup/cli.py
index 606a7d5..5e2da31 100644
--- a/bbackup/cli.py
+++ b/bbackup/cli.py
@@ -26,9 +26,10 @@
from .remote import RemoteStorageManager
from .archive import create_solid_archive
from .backup_runner import BackupRunner
-from .restore import DockerRestore
+from .restore import DockerRestore, list_volume_backup_names
from .logging import setup_logging
from .encryption import EncryptionManager
+from .resources import read_text_resource, resource_exists
from .cli_utils import (
output_option,
input_json_option,
@@ -38,7 +39,6 @@
json_error,
EXIT_SUCCESS,
EXIT_USER_ERROR,
- EXIT_CONFIG_ERROR,
EXIT_SYSTEM_ERROR,
EXIT_PARTIAL,
EXIT_CANCELLED,
@@ -47,8 +47,26 @@
from .skills import get_skill
-SKILLS_DOC_PATH = Path(__file__).parent.parent / "docs" / "cli-skills.md"
-SKILLS_INDEX_PATH = Path(__file__).parent.parent / "docs" / "cli-skills-index.json"
+SKILLS_DOC_RESOURCE = "cli-skills.md"
+SKILLS_INDEX_RESOURCE = "cli-skills-index.json"
+
+
+def _find_duplicate_filesystem_target_names(targets: List[FilesystemTarget]) -> List[str]:
+ seen = set()
+ duplicates = []
+ for target in targets:
+ if target.name in seen and target.name not in duplicates:
+ duplicates.append(target.name)
+ seen.add(target.name)
+ return duplicates
+
+
+def _backup_encryption_result(status: BackupStatus, backup_path: Path) -> str:
+ if status.encryption_status == "encrypted" or str(backup_path).endswith(".enc"):
+ return "encrypted"
+ if status.encryption_status == "failed":
+ return "failed"
+ return "disabled"
@click.group()
@@ -228,6 +246,13 @@ def backup(
for fs_set_obj in config.filesystem_sets.values():
filesystem_targets.extend(t for t in fs_set_obj.targets if t.enabled)
+ duplicate_target_names = _find_duplicate_filesystem_target_names(filesystem_targets)
+ if duplicate_target_names:
+ msg = f"Duplicate filesystem target name: {', '.join(duplicate_target_names)}"
+ if output != "json":
+ sys.stderr.write(f"Error: {msg}\n")
+ json_error("backup", msg, EXIT_USER_ERROR, output)
+
# Gap 9: dry-run support
if dry_run:
plan = {
@@ -279,6 +304,12 @@ def backup_operation():
filesystem_targets=filesystem_targets,
) or {}
+ run_errors = run_results.get("errors") if isinstance(run_results, dict) else []
+ if status.status in ("partial", "error") or run_errors:
+ if status.status != "error":
+ status.status = "partial"
+ return
+
if use_solid_archive and status.status != "cancelled":
status.update(action="Creating archive...", item="")
compression_cfg = config.get_backup_compression()
@@ -287,6 +318,10 @@ def backup_operation():
status.update(action="Encrypting archive...", item="")
upload_path = create_solid_archive(backup_dir, compression_cfg, enc_cfg)
backup_name = upload_path.name
+ if enc_cfg and str(upload_path).endswith(".enc"):
+ status.encryption_status = "encrypted"
+ if original_backup_dir.exists():
+ shutil.rmtree(original_backup_dir)
if remotes_to_use:
runner.upload_to_remotes(upload_path, backup_name, remotes_to_use)
any_ok = any(st == "success" for st in (status.remote_status or {}).values())
@@ -305,15 +340,19 @@ def backup_operation():
if encrypted_backup_dir != original_backup_dir:
backup_dir = encrypted_backup_dir
backup_name = encrypted_backup_dir.name
+ if original_backup_dir.exists():
+ shutil.rmtree(original_backup_dir)
if remotes_to_use and status.status != "cancelled":
runner.upload_to_remotes(backup_dir, backup_name, remotes_to_use)
- if status.status != "cancelled":
+ if status.status not in ("cancelled", "partial", "error"):
status.status = "completed"
except Exception as e:
- status.status = "error"
- status.add_error(str(e))
+ if status.status != "partial":
+ status.status = "error"
+ if str(e) not in status.errors:
+ status.add_error(str(e))
try:
if use_tui:
@@ -335,8 +374,8 @@ def backup_operation():
"volumes": status.volumes_status or {},
"networks": status.networks_status or {},
"filesystems": status.filesystems_status or {},
- "remotes": {r.name: "uploaded" for r in remotes_to_use if hasattr(r, "name")},
- "encryption": "encrypted" if config.encryption.enabled else "disabled",
+ "remotes": status.remote_status or {},
+ "encryption": _backup_encryption_result(status, backup_dir),
"errors": status.errors or [],
}
@@ -440,7 +479,7 @@ def restore(
if configs_dir.exists():
containers_to_restore = [f.stem.replace("_config", "") for f in configs_dir.glob("*_config.json")]
if volumes_dir.exists():
- volumes_to_restore = [d.name for d in volumes_dir.iterdir() if d.is_dir()]
+ volumes_to_restore = list_volume_backup_names(backup_path)
if networks_dir.exists():
networks_to_restore = [f.stem for f in networks_dir.glob("*.json")]
else:
@@ -813,10 +852,8 @@ def init_config(ctx, skills, output, input_json):
config_dir = os.path.dirname(config_path)
os.makedirs(config_dir, exist_ok=True)
- example_config = Path(__file__).parent.parent / "config.yaml.example"
- if example_config.exists():
- import shutil
- shutil.copy(example_config, config_path)
+ if resource_exists("config.yaml.example"):
+ Path(config_path).write_text(read_text_resource("config.yaml.example"), encoding="utf-8")
render_output({"config_path": config_path, "created": True}, output, "init-config")
if output != "json":
console.print(f"[green]Configuration file created: {config_path}[/green]")
@@ -837,8 +874,8 @@ def init_config(ctx, skills, output, input_json):
@click.option("--method", type=click.Choice(["symmetric", "asymmetric", "both"]),
default="symmetric", help="Encryption method to use")
@click.option("--key-path", type=click.Path(), help="Directory to save key(s) (default: ~/.config/bbackup/)")
-@click.option("--password", help="Password for key encryption (optional)")
-@click.option("--algorithm", type=click.Choice(["rsa-4096", "ecdsa-p384"]), default="rsa-4096",
+@click.option("--password", help="Password for key encryption (not currently supported)")
+@click.option("--algorithm", type=click.Choice(["rsa-4096"]), default="rsa-4096",
help="Algorithm for asymmetric keys")
@click.option("--upload-github", is_flag=True, help="Remind about uploading public key to GitHub")
@click.option(
@@ -857,6 +894,14 @@ def init_encryption(ctx, method, key_path, password, algorithm, upload_github, s
console: Console = ctx.obj["console"]
+ if password:
+ json_error(
+ "init-encryption",
+ "--password is not currently supported for generated keys",
+ EXIT_USER_ERROR,
+ output,
+ )
+
key_dir = Path(key_path).expanduser() if key_path else Path.home() / ".config" / "bbackup"
key_dir.mkdir(parents=True, exist_ok=True)
@@ -973,12 +1018,12 @@ def _print_command_skills(cli_name: str, command_name: str) -> None:
"""
Print the skills documentation section for a specific CLI command and exit.
"""
- if not SKILLS_DOC_PATH.exists() or not SKILLS_INDEX_PATH.exists():
+ if not resource_exists(SKILLS_DOC_RESOURCE) or not resource_exists(SKILLS_INDEX_RESOURCE):
sys.stderr.write("Skills documentation has not been generated yet.\n")
sys.exit(EXIT_SYSTEM_ERROR)
try:
- index = json.loads(SKILLS_INDEX_PATH.read_text(encoding="utf-8"))
+ index = json.loads(read_text_resource(SKILLS_INDEX_RESOURCE))
except Exception as exc:
sys.stderr.write(f"Failed to read skills index: {exc}\n")
sys.exit(EXIT_SYSTEM_ERROR)
@@ -989,7 +1034,7 @@ def _print_command_skills(cli_name: str, command_name: str) -> None:
sys.stderr.write(f"No skills entry found for command {cmd_id}.\n")
sys.exit(EXIT_USER_ERROR)
- lines = SKILLS_DOC_PATH.read_text(encoding="utf-8").splitlines()
+ lines = read_text_resource(SKILLS_DOC_RESOURCE).splitlines()
start = max(int(meta.get("start", 1)) - 1, 0)
end = min(int(meta.get("end", len(lines))), len(lines))
section = "\n".join(lines[start:end]) + "\n"
@@ -1001,10 +1046,10 @@ def _print_skills_markdown() -> None:
"""
Print the full Markdown skills catalog to stdout and exit.
"""
- if not SKILLS_DOC_PATH.exists():
+ if not resource_exists(SKILLS_DOC_RESOURCE):
sys.stderr.write("Skills documentation has not been generated yet.\n")
sys.exit(EXIT_SYSTEM_ERROR)
- sys.stdout.write(SKILLS_DOC_PATH.read_text(encoding="utf-8"))
+ sys.stdout.write(read_text_resource(SKILLS_DOC_RESOURCE))
sys.exit(EXIT_SUCCESS)
diff --git a/bbackup/cli_metadata.py b/bbackup/cli_metadata.py
index f198af1..2552be3 100644
--- a/bbackup/cli_metadata.py
+++ b/bbackup/cli_metadata.py
@@ -716,7 +716,7 @@ def _register_bbackup(cmd: CliCommand) -> None:
name="password",
kind="flag",
type="string",
- description="Password for key encryption (optional).",
+ description="Not currently supported for generated keys; command fails if provided.",
cli_flag="--password",
json_key="password",
),
@@ -727,7 +727,7 @@ def _register_bbackup(cmd: CliCommand) -> None:
description="Algorithm for asymmetric keys.",
cli_flag="--algorithm",
json_key="algorithm",
- allowed_values=["rsa-4096", "ecdsa-p384"],
+ allowed_values=["rsa-4096"],
shape="enum",
default="rsa-4096",
),
@@ -1508,4 +1508,3 @@ def get_command(cli: CliName, name: str) -> Optional[CliCommand]:
"""Lookup a command by cli + name. Returns None if unknown."""
registry = get_command_registry(cli)
return registry.get(f"{cli}:{name}")
-
diff --git a/bbackup/config.py b/bbackup/config.py
index 1dfb42d..a7f485c 100644
--- a/bbackup/config.py
+++ b/bbackup/config.py
@@ -242,6 +242,7 @@ def _parse_config(self):
)
for t in set_data.get("targets", [])
]
+ self._validate_unique_filesystem_target_names(targets)
self.filesystem_sets[set_name] = FilesystemBackupSet(
name=set_name,
description=set_data.get("description", ""),
@@ -313,7 +314,7 @@ def _parse_config(self):
use_link_dest=inc.get("use_link_dest", True),
min_file_size=inc.get("min_file_size", 1048576),
)
-
+
# Parse encryption settings
if "encryption" in self.data:
enc = self.data["encryption"]
@@ -326,6 +327,14 @@ def _parse_config(self):
encrypt_configs=enc.get("encrypt_configs", True),
encrypt_networks=enc.get("encrypt_networks", True),
)
+
+ def _validate_unique_filesystem_target_names(self, targets: List[FilesystemTarget]) -> None:
+ """Reject duplicate filesystem target names that would overwrite each other."""
+ seen = set()
+ for target in targets:
+ if target.name in seen:
+ raise ValueError(f"Duplicate filesystem target name: {target.name}")
+ seen.add(target.name)
def get_staging_dir(self) -> str:
"""Get local staging directory."""
diff --git a/bbackup/data/__init__.py b/bbackup/data/__init__.py
new file mode 100644
index 0000000..da9b558
--- /dev/null
+++ b/bbackup/data/__init__.py
@@ -0,0 +1 @@
+"""Bundled runtime resources for installed bbackup commands."""
diff --git a/bbackup/data/cli-skills-index.json b/bbackup/data/cli-skills-index.json
new file mode 100644
index 0000000..fb222d6
--- /dev/null
+++ b/bbackup/data/cli-skills-index.json
@@ -0,0 +1,90 @@
+{
+ "bbackup:backup": {
+ "end": 71,
+ "start": 7
+ },
+ "bbackup:init-config": {
+ "end": 102,
+ "start": 72
+ },
+ "bbackup:init-encryption": {
+ "end": 138,
+ "start": 103
+ },
+ "bbackup:list-backup-sets": {
+ "end": 169,
+ "start": 139
+ },
+ "bbackup:list-backups": {
+ "end": 201,
+ "start": 170
+ },
+ "bbackup:list-containers": {
+ "end": 232,
+ "start": 202
+ },
+ "bbackup:list-filesystem-sets": {
+ "end": 263,
+ "start": 233
+ },
+ "bbackup:list-remote-backups": {
+ "end": 295,
+ "start": 264
+ },
+ "bbackup:restore": {
+ "end": 350,
+ "start": 296
+ },
+ "bbackup:skills": {
+ "end": 380,
+ "start": 351
+ },
+ "bbman:check-deps": {
+ "end": 422,
+ "start": 381
+ },
+ "bbman:check-updates": {
+ "end": 454,
+ "start": 423
+ },
+ "bbman:cleanup": {
+ "end": 490,
+ "start": 455
+ },
+ "bbman:diagnostics": {
+ "end": 522,
+ "start": 491
+ },
+ "bbman:health": {
+ "end": 553,
+ "start": 523
+ },
+ "bbman:repo-url": {
+ "end": 585,
+ "start": 554
+ },
+ "bbman:run": {
+ "end": 606,
+ "start": 586
+ },
+ "bbman:setup": {
+ "end": 637,
+ "start": 607
+ },
+ "bbman:skills": {
+ "end": 665,
+ "start": 638
+ },
+ "bbman:status": {
+ "end": 696,
+ "start": 666
+ },
+ "bbman:update": {
+ "end": 730,
+ "start": 697
+ },
+ "bbman:validate-config": {
+ "end": 761,
+ "start": 731
+ }
+}
diff --git a/bbackup/data/cli-skills.md b/bbackup/data/cli-skills.md
new file mode 100644
index 0000000..d9c23d1
--- /dev/null
+++ b/bbackup/data/cli-skills.md
@@ -0,0 +1,760 @@
+# CLI skills catalog
+
+> Generated from the bbackup/bbman CLI metadata. Version: 1.8.0. This catalog is authoritative for this version.
+
+## bbackup
+
+### bbackup backup
+
+**Summary**: Create Docker and/or filesystem backup.
+
+Back up one or more Docker containers and optional filesystem paths. Supports incremental rsync (--link-dest), multiple remotes, and non-interactive JSON-driven operation.
+
+#### CLI parameters
+
+| Name | Type | Required | Default | Description |
+|---|---|:---:|---|---|
+| `--containers` | `string[]` | no | `` | Container names to back up (repeatable). |
+| `--backup-set` | `string` | no | `` | Named backup set from config.yaml. |
+| `--config-only` | `bool` | no | `False` | Back up only container configs (no volumes). |
+| `--volumes-only` | `bool` | no | `False` | Back up only volumes (no configs). |
+| `--no-networks` | `bool` | no | `False` | Skip network backups. |
+| `--incremental` | `bool` | no | `False` | Enable incremental backup via rsync --link-dest. |
+| `--no-interactive` | `bool` | no | `False` | Disable TUI and prompts; required for agent use. |
+| `--solid-archive/--no-solid-archive` | `bool` | no | `` | Create a single compressed tarball for upload (overrides config backup.solid_archive). Use --no-solid-archive to disable. |
+| `--remote` | `string[]` | no | `` | Remote storage destinations (repeatable). |
+| `--paths` | `string[]` | no | `` | Filesystem paths to back up (repeatable). |
+| `--exclude` | `string[]` | no | `` | Exclude patterns for filesystem backup (repeatable). |
+| `--filesystem-set` | `string` | no | `` | Named filesystem backup set from config.yaml. |
+| `--dry-run` | `bool` | no | `False` | Resolve targets and return a plan without executing. |
+| `--output` | `string` | no | `` | Output format: text or json. |
+
+#### JSON / environment parameters
+
+| Name | Kind | Type | Required | Default | Description |
+|---|---|---|:---:|---|---|
+| `input_json` | json | `object` | no | `` | Flat JSON object providing all parameters. |
+
+#### Examples
+
+- Backup specific containers non-interactively with JSON output.
+
+ ```bash
+ bbackup backup --containers myapp --no-interactive --output json
+ ```
+
+- Incremental backup of a named backup set to a remote.
+
+ ```bash
+ bbackup backup --backup-set production --incremental --remote gdrive --no-interactive --output json
+ ```
+
+- JSON-driven backup of two containers.
+
+ ```bash
+ bbackup backup --input-json '{"containers":["myapp","mydb"],"incremental":true,"no_interactive":true}' --output json
+ ```
+
+ ```bash
+ bbackup backup --input-json '{"containers":["myapp","mydb"],"incremental":true,"no_interactive":true,"output":"json"}' --output json
+ ```
+
+- Dry-run to see what would be backed up.
+
+ ```bash
+ bbackup backup --backup-set production --dry-run --no-interactive --output json
+ ```
+
+ ```bash
+ bbackup backup --input-json '{"backup_set":"production","dry_run":true,"no_interactive":true,"output":"json"}' --output json
+ ```
+
+### bbackup init-config
+
+**Summary**: Initialize configuration file from the bundled example template.
+
+Create an example config.yaml in ~/.config/bbackup/ from the bundled template.
+
+#### CLI parameters
+
+| Name | Type | Required | Default | Description |
+|---|---|:---:|---|---|
+| `--skills` | `bool` | no | `False` | Show skills documentation for this command and exit. |
+| `--output` | `string` | no | `` | Output format: text or json. |
+
+#### JSON / environment parameters
+
+| Name | Kind | Type | Required | Default | Description |
+|---|---|---|:---:|---|---|
+| `input_json` | json | `object` | no | `` | Flat JSON object providing all parameters. |
+
+#### Examples
+
+- Initialize a starter config file.
+
+ ```bash
+ bbackup init-config --output json
+ ```
+
+ ```bash
+ bbackup init-config --input-json '{"output":"json"}' --output json
+ ```
+
+### bbackup init-encryption
+
+**Summary**: Initialize encryption keys for backup at-rest protection.
+
+Generate symmetric and/or asymmetric keys for encrypting backups at rest and return a config snippet.
+
+#### CLI parameters
+
+| Name | Type | Required | Default | Description |
+|---|---|:---:|---|---|
+| `--method` | `string` | no | `'symmetric'` | Encryption method to use. |
+| `--key-path` | `path` | no | `` | Directory to save key(s) (default: ~/.config/bbackup/). |
+| `--password` | `string` | no | `` | Not currently supported for generated keys; command fails if provided. |
+| `--algorithm` | `string` | no | `'rsa-4096'` | Algorithm for asymmetric keys. |
+| `--upload-github` | `bool` | no | `False` | Remind about uploading public key to GitHub. |
+| `--skills` | `bool` | no | `False` | Show skills documentation for this command and exit. |
+| `--output` | `string` | no | `` | Output format: text or json. |
+
+#### JSON / environment parameters
+
+| Name | Kind | Type | Required | Default | Description |
+|---|---|---|:---:|---|---|
+| `input_json` | json | `object` | no | `` | Flat JSON object providing all parameters. |
+
+#### Examples
+
+- Generate asymmetric keys with JSON output.
+
+ ```bash
+ bbackup init-encryption --method asymmetric --algorithm rsa-4096 --output json
+ ```
+
+ ```bash
+ bbackup init-encryption --input-json '{"method":"asymmetric","algorithm":"rsa-4096","output":"json"}' --output json
+ ```
+
+### bbackup list-backup-sets
+
+**Summary**: List available backup sets.
+
+List named backup sets from config with containers and scope.
+
+#### CLI parameters
+
+| Name | Type | Required | Default | Description |
+|---|---|:---:|---|---|
+| `--skills` | `bool` | no | `False` | Show skills documentation for this command and exit. |
+| `--output` | `string` | no | `` | Output format: text or json. |
+
+#### JSON / environment parameters
+
+| Name | Kind | Type | Required | Default | Description |
+|---|---|---|:---:|---|---|
+| `input_json` | json | `object` | no | `` | Flat JSON object providing all parameters. |
+
+#### Examples
+
+- List backup sets with JSON output.
+
+ ```bash
+ bbackup list-backup-sets --output json
+ ```
+
+ ```bash
+ bbackup list-backup-sets --input-json '{"output":"json"}' --output json
+ ```
+
+### bbackup list-backups
+
+**Summary**: List available local backups.
+
+List local backup directories in the staging directory or a specified location.
+
+#### CLI parameters
+
+| Name | Type | Required | Default | Description |
+|---|---|:---:|---|---|
+| `--backup-dir` | `path` | no | `` | Backup directory to list (default: staging directory). |
+| `--skills` | `bool` | no | `False` | Show skills documentation for this command and exit. |
+| `--output` | `string` | no | `` | Output format: text or json. |
+
+#### JSON / environment parameters
+
+| Name | Kind | Type | Required | Default | Description |
+|---|---|---|:---:|---|---|
+| `input_json` | json | `object` | no | `` | Flat JSON object providing all parameters. |
+
+#### Examples
+
+- List local backups with JSON output.
+
+ ```bash
+ bbackup list-backups --output json
+ ```
+
+ ```bash
+ bbackup list-backups --input-json '{"output":"json"}' --output json
+ ```
+
+### bbackup list-containers
+
+**Summary**: List all Docker containers.
+
+List Docker containers with id, name, status, and image for inspection or backup planning.
+
+#### CLI parameters
+
+| Name | Type | Required | Default | Description |
+|---|---|:---:|---|---|
+| `--skills` | `bool` | no | `False` | Show skills documentation for this command and exit. |
+| `--output` | `string` | no | `` | Output format: text or json. |
+
+#### JSON / environment parameters
+
+| Name | Kind | Type | Required | Default | Description |
+|---|---|---|:---:|---|---|
+| `input_json` | json | `object` | no | `` | Flat JSON object providing all parameters. |
+
+#### Examples
+
+- List all containers with JSON details.
+
+ ```bash
+ bbackup list-containers --output json
+ ```
+
+ ```bash
+ bbackup list-containers --input-json '{"output":"json"}' --output json
+ ```
+
+### bbackup list-filesystem-sets
+
+**Summary**: List configured filesystem backup sets.
+
+List filesystem backup sets defined in config with targets and excludes.
+
+#### CLI parameters
+
+| Name | Type | Required | Default | Description |
+|---|---|:---:|---|---|
+| `--skills` | `bool` | no | `False` | Show skills documentation for this command and exit. |
+| `--output` | `string` | no | `` | Output format: text or json. |
+
+#### JSON / environment parameters
+
+| Name | Kind | Type | Required | Default | Description |
+|---|---|---|:---:|---|---|
+| `input_json` | json | `object` | no | `` | Flat JSON object providing all parameters. |
+
+#### Examples
+
+- List filesystem backup sets with JSON output.
+
+ ```bash
+ bbackup list-filesystem-sets --output json
+ ```
+
+ ```bash
+ bbackup list-filesystem-sets --input-json '{"output":"json"}' --output json
+ ```
+
+### bbackup list-remote-backups
+
+**Summary**: List backups stored on a configured remote.
+
+List available backups on a configured remote storage destination.
+
+#### CLI parameters
+
+| Name | Type | Required | Default | Description |
+|---|---|:---:|---|---|
+| `--remote` | `string` | yes | `` | Remote storage name to list backups from. |
+| `--skills` | `bool` | no | `False` | Show skills documentation for this command and exit. |
+| `--output` | `string` | no | `` | Output format: text or json. |
+
+#### JSON / environment parameters
+
+| Name | Kind | Type | Required | Default | Description |
+|---|---|---|:---:|---|---|
+| `input_json` | json | `object` | no | `` | Flat JSON object providing all parameters. |
+
+#### Examples
+
+- List remote backups on a given remote.
+
+ ```bash
+ bbackup list-remote-backups --remote gdrive --output json
+ ```
+
+ ```bash
+ bbackup list-remote-backups --input-json '{"remote":"gdrive","output":"json"}' --output json
+ ```
+
+### bbackup restore
+
+**Summary**: Restore containers, volumes, networks, or filesystem paths from a backup.
+
+Restore Docker resources and filesystem targets from a backup directory. Supports full restores, targeted restores, rename mappings, and dry-run mode.
+
+#### CLI parameters
+
+| Name | Type | Required | Default | Description |
+|---|---|:---:|---|---|
+| `--backup-path` | `path` | yes | `` | Path to the backup directory. |
+| `--all` | `bool` | no | `False` | Restore all items from the backup. |
+| `--containers` | `string[]` | no | `` | Specific container names to restore (repeatable). |
+| `--volumes` | `string[]` | no | `` | Specific volume names to restore (repeatable). |
+| `--networks` | `string[]` | no | `` | Specific network names to restore (repeatable). |
+| `--filesystem` | `string[]` | no | `` | Filesystem target names to restore (repeatable). |
+| `--filesystem-destination` | `path` | no | `` | Destination path for filesystem restore. |
+| `--rename` | `string[]` | no | `` | Rename mappings in old:new format (repeatable). |
+| `--dry-run` | `bool` | no | `False` | Return a restore plan without executing. |
+| `--output` | `string` | no | `` | Output format: text or json. |
+
+#### JSON / environment parameters
+
+| Name | Kind | Type | Required | Default | Description |
+|---|---|---|:---:|---|---|
+| `input_json` | json | `object` | no | `` | Flat JSON object providing all parameters. |
+
+#### Examples
+
+- Restore everything from a backup directory.
+
+ ```bash
+ bbackup restore --backup-path /tmp/bbackup/backup_20260227_120000 --all --output json
+ ```
+
+- Restore a single container from a backup using JSON input.
+
+ ```bash
+ bbackup restore --input-json '{"backup_path":"/tmp/bbackup/backup_20260227_120000","containers":["myapp"]}' --output json
+ ```
+
+ ```bash
+ bbackup restore --input-json '{"backup_path":"/tmp/bbackup/backup_20260227_120000","containers":["myapp"],"output":"json"}' --output json
+ ```
+
+- Dry-run restore to inspect what would be restored.
+
+ ```bash
+ bbackup restore --backup-path /tmp/bbackup/backup_20260227_120000 --all --dry-run --output json
+ ```
+
+ ```bash
+ bbackup restore --input-json '{"backup_path":"/tmp/bbackup/backup_20260227_120000","all":true,"dry_run":true,"output":"json"}' --output json
+ ```
+
+### bbackup skills
+
+**Summary**: List available bbackup skills for AI agent discovery.
+
+List or inspect bbackup skills in JSON or Markdown formats.
+
+#### CLI parameters
+
+| Name | Type | Required | Default | Description |
+|---|---|:---:|---|---|
+| `skill_id` | `string` | no | `` | Optional skill id for detailed view. |
+| `--format` | `string` | no | `'json'` | Output as JSON or Markdown skills catalog. |
+| `--output` | `string` | no | `` | Output format for detailed skill view (text or json). |
+
+#### Examples
+
+- List all bbackup skills in JSON.
+
+ ```bash
+ bbackup skills
+ ```
+
+- Dump the full Markdown skills catalog.
+
+ ```bash
+ bbackup skills --format markdown
+ ```
+
+## bbman
+
+### bbman check-deps
+
+**Summary**: Check and optionally install missing dependencies.
+
+Check required and optional system and Python dependencies, optionally installing missing ones.
+
+#### CLI parameters
+
+| Name | Type | Required | Default | Description |
+|---|---|:---:|---|---|
+| `--install` | `bool` | no | `False` | Install missing packages. |
+| `--skills` | `bool` | no | `False` | Show skills documentation for this command and exit. |
+| `--output` | `string` | no | `` | Output format: text or json. |
+
+#### JSON / environment parameters
+
+| Name | Kind | Type | Required | Default | Description |
+|---|---|---|:---:|---|---|
+| `input_json` | json | `object` | no | `` | Flat JSON object providing all parameters. |
+
+#### Examples
+
+- Check dependencies only.
+
+ ```bash
+ bbman check-deps --output json
+ ```
+
+ ```bash
+ bbman check-deps --input-json '{"output":"json"}' --output json
+ ```
+
+- Check and install missing dependencies.
+
+ ```bash
+ bbman check-deps --install --output json
+ ```
+
+ ```bash
+ bbman check-deps --input-json '{"install":true,"output":"json"}' --output json
+ ```
+
+### bbman check-updates
+
+**Summary**: Check for updates (file-level comparison with checksums).
+
+Check whether the installed version is behind the configured repository.
+
+#### CLI parameters
+
+| Name | Type | Required | Default | Description |
+|---|---|:---:|---|---|
+| `--branch` | `string` | no | `'main'` | Branch to check (default: main). |
+| `--skills` | `bool` | no | `False` | Show skills documentation for this command and exit. |
+| `--output` | `string` | no | `` | Output format: text or json. |
+
+#### JSON / environment parameters
+
+| Name | Kind | Type | Required | Default | Description |
+|---|---|---|:---:|---|---|
+| `input_json` | json | `object` | no | `` | Flat JSON object providing all parameters. |
+
+#### Examples
+
+- Check for updates on main branch.
+
+ ```bash
+ bbman check-updates --output json
+ ```
+
+ ```bash
+ bbman check-updates --input-json '{"output":"json"}' --output json
+ ```
+
+### bbman cleanup
+
+**Summary**: Cleanup old files and backups.
+
+Remove old staging, log, backup, and temp files according to retention parameters.
+
+#### CLI parameters
+
+| Name | Type | Required | Default | Description |
+|---|---|:---:|---|---|
+| `--staging-days` | `int` | no | `7` | Keep staging files newer than N days (default 7). |
+| `--log-days` | `int` | no | `30` | Keep log files newer than N days (default 30). |
+| `--no-backups` | `bool` | no | `False` | Do not cleanup old backups. |
+| `--no-temp` | `bool` | no | `False` | Do not cleanup temporary files. |
+| `--yes` | `bool` | no | `False` | Skip confirmation prompt. |
+| `--skills` | `bool` | no | `False` | Show skills documentation for this command and exit. |
+| `--output` | `string` | no | `` | Output format: text or json. |
+
+#### JSON / environment parameters
+
+| Name | Kind | Type | Required | Default | Description |
+|---|---|---|:---:|---|---|
+| `input_json` | json | `object` | no | `` | Flat JSON object providing all parameters. |
+
+#### Examples
+
+- Cleanup with default retention settings and JSON output.
+
+ ```bash
+ bbman cleanup --yes --output json
+ ```
+
+ ```bash
+ bbman cleanup --input-json '{"yes":true,"output":"json"}' --output json
+ ```
+
+### bbman diagnostics
+
+**Summary**: Run diagnostics and optionally save report to file.
+
+Run diagnostics and optionally save a detailed report to file for troubleshooting.
+
+#### CLI parameters
+
+| Name | Type | Required | Default | Description |
+|---|---|:---:|---|---|
+| `--report-file` | `path` | no | `` | Save diagnostics report to this file path. |
+| `--skills` | `bool` | no | `False` | Show skills documentation for this command and exit. |
+| `--output` | `string` | no | `` | Output format: text or json. |
+
+#### JSON / environment parameters
+
+| Name | Kind | Type | Required | Default | Description |
+|---|---|---|:---:|---|---|
+| `input_json` | json | `object` | no | `` | Flat JSON object providing all parameters. |
+
+#### Examples
+
+- Run diagnostics and return JSON summary.
+
+ ```bash
+ bbman diagnostics --output json
+ ```
+
+ ```bash
+ bbman diagnostics --input-json '{"output":"json"}' --output json
+ ```
+
+### bbman health
+
+**Summary**: Run comprehensive health check (Docker, rsync, rclone, Python packages).
+
+Check Docker connectivity, system tools, Python dependencies, and configuration health. Designed for both human and agent consumption.
+
+#### CLI parameters
+
+| Name | Type | Required | Default | Description |
+|---|---|:---:|---|---|
+| `--skills` | `bool` | no | `False` | Show skills documentation for this command and exit. |
+| `--output` | `string` | no | `` | Output format: text or json. |
+
+#### JSON / environment parameters
+
+| Name | Kind | Type | Required | Default | Description |
+|---|---|---|:---:|---|---|
+| `input_json` | json | `object` | no | `` | Flat JSON object providing all parameters. |
+
+#### Examples
+
+- Run health check with JSON result.
+
+ ```bash
+ bbman health --output json
+ ```
+
+ ```bash
+ bbman health --input-json '{"output":"json"}' --output json
+ ```
+
+### bbman repo-url
+
+**Summary**: Show or set the repository URL override.
+
+Show or update the repository URL used for update checks and downloads.
+
+#### CLI parameters
+
+| Name | Type | Required | Default | Description |
+|---|---|:---:|---|---|
+| `--url` | `string` | no | `` | Set repository URL override. |
+| `--skills` | `bool` | no | `False` | Show skills documentation for this command and exit. |
+| `--output` | `string` | no | `` | Output format: text or json. |
+
+#### JSON / environment parameters
+
+| Name | Kind | Type | Required | Default | Description |
+|---|---|---|:---:|---|---|
+| `input_json` | json | `object` | no | `` | Flat JSON object providing all parameters. |
+
+#### Examples
+
+- Show current repository URL in JSON.
+
+ ```bash
+ bbman repo-url --output json
+ ```
+
+ ```bash
+ bbman repo-url --input-json '{"output":"json"}' --output json
+ ```
+
+### bbman run
+
+**Summary**: Run bbackup commands through the bbman wrapper.
+
+Launch the main bbackup CLI through bbman, preserving JSON envelope behavior when requested.
+
+#### CLI parameters
+
+| Name | Type | Required | Default | Description |
+|---|---|:---:|---|---|
+| `command` | `string[]` | no | `` | The bbackup command and arguments to run. |
+| `--output` | `string` | no | `` | Output format: text or json. |
+
+#### Examples
+
+- Run a backup through bbman with JSON output.
+
+ ```bash
+ bbman run backup --containers myapp --no-interactive --output json
+ ```
+
+### bbman setup
+
+**Summary**: Run interactive setup wizard for first-time configuration.
+
+Run the interactive setup wizard to create an initial config.yaml. In agent mode, use --no-interactive with BBACKUP_NO_INTERACTIVE=1 to query current state instead of running the wizard.
+
+#### CLI parameters
+
+| Name | Type | Required | Default | Description |
+|---|---|:---:|---|---|
+| `--no-interactive` | `bool` | no | `False` | Skip wizard; return current config state (agent mode). |
+| `--output` | `string` | no | `` | Output format: text or json. |
+
+#### JSON / environment parameters
+
+| Name | Kind | Type | Required | Default | Description |
+|---|---|---|:---:|---|---|
+| `input_json` | json | `object` | no | `` | Flat JSON object providing all parameters. |
+
+#### Examples
+
+- Run setup in non-interactive mode for an agent.
+
+ ```bash
+ bbman setup --no-interactive --output json
+ ```
+
+ ```bash
+ bbman setup --input-json '{"no_interactive":true,"output":"json"}' --output json
+ ```
+
+### bbman skills
+
+**Summary**: List available bbman skills for AI agent discovery.
+
+List or inspect bbman skills in JSON or Markdown formats.
+
+#### CLI parameters
+
+| Name | Type | Required | Default | Description |
+|---|---|:---:|---|---|
+| `skill_id` | `string` | no | `` | Optional skill id for detailed view. |
+| `--format` | `string` | no | `'json'` | Output as JSON or Markdown skills catalog. |
+| `--output` | `string` | no | `` | Output format for detailed skill view (text or json). |
+
+#### Examples
+
+- List all bbman skills in JSON.
+
+ ```bash
+ bbman skills
+ ```
+
+- Dump the full Markdown skills catalog.
+
+ ```bash
+ bbman skills --format markdown
+ ```
+
+### bbman status
+
+**Summary**: Show backup status and history.
+
+Show backup statistics and history, suitable for both humans and agents.
+
+#### CLI parameters
+
+| Name | Type | Required | Default | Description |
+|---|---|:---:|---|---|
+| `--skills` | `bool` | no | `False` | Show skills documentation for this command and exit. |
+| `--output` | `string` | no | `` | Output format: text or json. |
+
+#### JSON / environment parameters
+
+| Name | Kind | Type | Required | Default | Description |
+|---|---|---|:---:|---|---|
+| `input_json` | json | `object` | no | `` | Flat JSON object providing all parameters. |
+
+#### Examples
+
+- Show backup status with JSON output.
+
+ ```bash
+ bbman status --output json
+ ```
+
+ ```bash
+ bbman status --input-json '{"output":"json"}' --output json
+ ```
+
+### bbman update
+
+**Summary**: Update application files.
+
+Update the local installation from the configured repository using git or download methods.
+
+#### CLI parameters
+
+| Name | Type | Required | Default | Description |
+|---|---|:---:|---|---|
+| `--branch` | `string` | no | `'main'` | Branch to update from (default: main). |
+| `--method` | `string` | no | `'git'` | Update method (git or download). |
+| `--yes` | `bool` | no | `False` | Skip confirmation prompt. |
+| `--skills` | `bool` | no | `False` | Show skills documentation for this command and exit. |
+| `--output` | `string` | no | `` | Output format: text or json. |
+
+#### JSON / environment parameters
+
+| Name | Kind | Type | Required | Default | Description |
+|---|---|---|:---:|---|---|
+| `input_json` | json | `object` | no | `` | Flat JSON object providing all parameters. |
+
+#### Examples
+
+- Update non-interactively using git.
+
+ ```bash
+ bbman update --yes --output json
+ ```
+
+ ```bash
+ bbman update --input-json '{"yes":true,"output":"json"}' --output json
+ ```
+
+### bbman validate-config
+
+**Summary**: Validate configuration file.
+
+Validate config.yaml and report backup sets, remotes, and encryption status.
+
+#### CLI parameters
+
+| Name | Type | Required | Default | Description |
+|---|---|:---:|---|---|
+| `--skills` | `bool` | no | `False` | Show skills documentation for this command and exit. |
+| `--output` | `string` | no | `` | Output format: text or json. |
+
+#### JSON / environment parameters
+
+| Name | Kind | Type | Required | Default | Description |
+|---|---|---|:---:|---|---|
+| `input_json` | json | `object` | no | `` | Flat JSON object providing all parameters. |
+
+#### Examples
+
+- Validate configuration file with JSON output.
+
+ ```bash
+ bbman validate-config --output json
+ ```
+
+ ```bash
+ bbman validate-config --input-json '{"output":"json"}' --output json
+ ```
diff --git a/bbackup/data/config.yaml.example b/bbackup/data/config.yaml.example
new file mode 100644
index 0000000..3658ed9
--- /dev/null
+++ b/bbackup/data/config.yaml.example
@@ -0,0 +1,193 @@
+# bbackup Configuration File
+# Copy this to ~/.config/bbackup/config.yaml or /etc/bbackup/config.yaml
+
+# Backup Settings
+backup:
+ # Default backup directory (local staging area)
+ local_staging: /tmp/bbackup_staging
+
+ # Create a single compressed tarball (and optionally encrypt it) before upload.
+ # When true, remotes receive one file instead of many; staging cleanup runs only after at least one successful upload.
+ # solid_archive: false
+
+ # Compression settings
+ compression:
+ enabled: true
+ level: 6 # 1-9, higher = more compression but slower
+ format: gzip # gzip, bzip2, xz
+
+ # What to backup by default
+ default_scope:
+ containers: true
+ volumes: true
+ networks: true
+ configs: true
+
+ # Backup sets - predefined groups of containers
+ backup_sets:
+ production:
+ description: "Production containers"
+ containers:
+ - dms
+ - minio
+ - npm
+ - portainer
+ scope:
+ volumes: true
+ configs: true
+ networks: true
+
+ media:
+ description: "Media stack containers"
+ containers:
+ - arr_plex
+ - arr_sonarr
+ - arr_radarr
+ - arr_sabnzbd
+ scope:
+ volumes: true
+ configs: true
+
+ config_only:
+ description: "Configuration only (no data volumes)"
+ containers:
+ - npm
+ - portainer
+ scope:
+ volumes: false
+ configs: true
+ networks: true
+
+# Optional: default rclone transfer options for all rclone remotes (per-remote overrides this)
+# rclone:
+# default_options:
+# transfers: 8 # parallel file transfers (1-32)
+# checkers: 8 # parallel checkers for listing (1-32)
+
+# Remote Storage Destinations
+remotes:
+ # Google Drive via rclone
+ gdrive:
+ enabled: false
+ type: rclone
+ remote_name: gdrive # rclone remote name (must be configured separately)
+ path: /backups/docker
+ compression: true
+ # Optional: tune transfer concurrency for this remote (overrides rclone.default_options)
+ # rclone_options:
+ # transfers: 8
+ # checkers: 8
+
+ # SSH/SFTP Remote
+ ssh_server:
+ enabled: false
+ type: sftp
+ host: backup.example.com
+ port: 22
+ user: backup
+ key_file: ~/.ssh/backup_key
+ path: /backups/docker
+ compression: true
+
+ # Local directory (for testing)
+ local:
+ enabled: true
+ type: local
+ path: ~/backups/docker
+ compression: true
+
+# Backup Rotation & Retention
+retention:
+ # Time-based retention
+ daily: 7 # Keep 7 daily backups
+ weekly: 4 # Keep 4 weekly backups
+ monthly: 12 # Keep 12 monthly backups
+
+ # Storage quota limits (in GB, 0 = disabled)
+ max_storage_gb: 0
+ warning_threshold_percent: 80 # Warn when storage exceeds this %
+ cleanup_threshold_percent: 90 # Start cleanup when storage exceeds this %
+
+ # Cleanup strategy when quota exceeded
+ cleanup_strategy: oldest_first # oldest_first, least_important_first
+
+# Differential/Incremental Backup Settings
+incremental:
+ enabled: true
+ # Use rsync --link-dest for incremental backups
+ use_link_dest: true
+ # Minimum file size (bytes) to use incremental for
+ min_file_size: 1048576 # 1MB
+
+# Logging
+logging:
+ level: INFO # DEBUG, INFO, WARNING, ERROR
+ file: ~/.local/share/bbackup/bbackup.log
+ max_size_mb: 10
+ backup_count: 5
+
+# Encryption Settings
+encryption:
+ enabled: false # Set to true to enable encryption
+ method: symmetric # symmetric, asymmetric, or both
+ symmetric:
+ # Use either key_file (local) or key_url (remote)
+ key_file: ~/.config/bbackup/encryption.key
+ # OR use URL: key_url: https://raw.githubusercontent.com/user/repo/backup.key
+ key_password: null # Advanced: derive key from password using key_file bytes as salt
+ algorithm: aes-256-gcm
+ asymmetric:
+ # Public key can be file path OR URL (auto-detected)
+ public_key: ~/.config/bbackup/backup_public.pem
+ # OR: public_key: https://raw.githubusercontent.com/user/repo/backup_public.pem
+ # OR: public_key: https://gist.githubusercontent.com/user/gist_id/raw/backup_public.pem
+ private_key: ~/.config/bbackup/backup_private.pem
+ # Private key should always be local file (never URL for security)
+ private_key_password: null # Optional password for private key
+ algorithm: rsa-4096
+ verify_key_signature: false # If true, verify key hasn't been tampered with
+ encrypt_volumes: true
+ encrypt_configs: true
+ encrypt_networks: true
+
+# Filesystem Backup Sets
+# Groups of local paths to back up with rsync (no Docker required).
+# Use: bbackup backup --filesystem-set
+filesystem:
+ home-data:
+ description: "Important home directory data"
+ targets:
+ - name: documents
+ path: /home/user/Documents
+ enabled: true
+ excludes:
+ - "*.tmp"
+ - ".cache/"
+ - "node_modules/"
+ - name: projects
+ path: /home/user/projects
+ enabled: true
+ excludes:
+ - ".git/"
+ - "__pycache__/"
+ - "*.pyc"
+ - "dist/"
+ - "build/"
+ server-configs:
+ description: "Critical server configuration files"
+ targets:
+ - name: etc-nginx
+ path: /etc/nginx
+ enabled: true
+ excludes: []
+ - name: etc-app
+ path: /etc/myapp
+ enabled: true
+ excludes:
+ - "*.log"
+
+# Docker Settings
+docker:
+ socket: /var/run/docker.sock
+ # Timeout for docker commands (seconds)
+ timeout: 300
diff --git a/bbackup/docker_backup.py b/bbackup/docker_backup.py
index 37fcd82..58c8e64 100644
--- a/bbackup/docker_backup.py
+++ b/bbackup/docker_backup.py
@@ -212,10 +212,21 @@ def __init__(self, code):
if result.returncode == 0:
# Copy from container to host
- subprocess.run(
+ copy_result = subprocess.run(
["docker", "cp", f"{temp_container_name}:/tmp/backup/.", str(volume_backup_dir)],
+ capture_output=True,
+ text=True,
check=False,
)
+ if copy_result.returncode != 0:
+ logger.error(
+ f"docker cp backup failed for volume {volume_name}: {copy_result.stderr.strip()}"
+ )
+ raise RuntimeError(f"docker cp failed for volume {volume_name}")
+ else:
+ stderr = getattr(result, "stderr", "")
+ logger.error(f"rsync backup failed for volume {volume_name}: {stderr.strip()}")
+ raise RuntimeError(f"rsync failed for volume {volume_name}")
else:
# Fallback to tar if rsync not available
tar_cmd = [
@@ -227,14 +238,25 @@ def __init__(self, code):
if result.returncode == 0:
# Copy tar from container and extract
temp_tar = backup_dir / "volumes" / f"{volume_name}.tar.gz"
- subprocess.run(
+ copy_result = subprocess.run(
["docker", "cp", f"{temp_container_name}:/tmp/volume_backup.tar.gz", str(temp_tar)],
+ capture_output=True,
+ text=True,
check=False,
)
+ if copy_result.returncode != 0:
+ logger.error(
+ f"docker cp tar backup failed for volume {volume_name}: {copy_result.stderr.strip()}"
+ )
+ raise RuntimeError(f"docker cp failed for volume {volume_name}")
# Extract tar (tarfile imported at module level)
with tarfile.open(temp_tar, "r:gz") as tar:
- tar.extractall(volume_backup_dir)
+ tar.extractall(volume_backup_dir, filter="data")
temp_tar.unlink()
+ else:
+ stderr = getattr(result, "stderr", "")
+ logger.error(f"tar backup failed for volume {volume_name}: {stderr.strip()}")
+ raise RuntimeError(f"tar failed for volume {volume_name}")
# Cleanup
temp_container.stop()
@@ -277,6 +299,11 @@ def __init__(self, code):
temp_container.remove()
except Exception as cleanup_error:
logger.error(f"Error during cleanup: {cleanup_error}")
+ if volume_backup_dir.exists():
+ try:
+ shutil.rmtree(volume_backup_dir)
+ except OSError as cleanup_error:
+ logger.error(f"Error removing failed volume backup artifact: {cleanup_error}")
logger.error(f"Failed to backup volume {volume_name}: {e}")
return False
diff --git a/bbackup/encryption.py b/bbackup/encryption.py
index b394b41..749441d 100644
--- a/bbackup/encryption.py
+++ b/bbackup/encryption.py
@@ -667,7 +667,7 @@ def generate_keypair(algorithm: str = 'rsa-4096') -> Tuple[bytes, bytes]:
Generate asymmetric keypair.
Args:
- algorithm: 'rsa-4096' or 'ecdsa-p384'
+ algorithm: 'rsa-4096'
Returns:
Tuple of (public_key_bytes, private_key_bytes) in PEM format
@@ -678,12 +678,6 @@ def generate_keypair(algorithm: str = 'rsa-4096') -> Tuple[bytes, bytes]:
key_size=4096,
backend=default_backend()
)
- elif algorithm == 'ecdsa-p384':
- from cryptography.hazmat.primitives.asymmetric import ec
- private_key = ec.generate_private_key(
- ec.SECP384R1(),
- backend=default_backend()
- )
else:
raise ValueError(f"Unsupported algorithm: {algorithm}")
diff --git a/bbackup/management/dependencies.py b/bbackup/management/dependencies.py
index 27dd6fd..93e3d9a 100644
--- a/bbackup/management/dependencies.py
+++ b/bbackup/management/dependencies.py
@@ -4,6 +4,8 @@
import subprocess
import sys
+import re
+import tomllib
from pathlib import Path
from typing import Dict, List, Tuple
@@ -90,25 +92,29 @@ def check_python_dependencies() -> Tuple[bool, List[str], List[str]]:
return len(missing) == 0, installed, missing
-def check_requirements_file() -> List[str]:
+def _package_name(requirement: str) -> str:
+ """Extract a normalized package name from a PEP 508 requirement string."""
+ requirement = requirement.split(";", 1)[0].strip()
+ requirement = requirement.split("[", 1)[0].strip()
+ return re.split(r"[<>=!~]", requirement, maxsplit=1)[0].strip()
+
+
+def check_project_dependencies() -> List[str]:
"""
- Read requirements from requirements.txt.
+ Read runtime dependency names from pyproject.toml.
Returns:
- List of package names from requirements.txt
+ List of package names from pyproject.toml
"""
- req_file = Path(__file__).parent.parent.parent / "requirements.txt"
+ pyproject_file = Path(__file__).parent.parent.parent / "pyproject.toml"
packages = []
- if req_file.exists():
- with open(req_file, 'r') as f:
- for line in f:
- line = line.strip()
- if line and not line.startswith('#'):
- # Extract package name (before ==, >=, etc.)
- package = line.split('>=')[0].split('==')[0].split('>')[0].split('<')[0].strip()
- if package:
- packages.append(package)
+ if pyproject_file.exists():
+ data = tomllib.loads(pyproject_file.read_text(encoding="utf-8"))
+ for dependency in data.get("project", {}).get("dependencies", []):
+ package = _package_name(dependency)
+ if package:
+ packages.append(package)
return packages
@@ -126,7 +132,7 @@ def is_externally_managed() -> bool:
Return True if this Python install is marked as externally managed (PEP 668).
Modern Debian/Ubuntu ship an EXTERNALLY-MANAGED marker file alongside the
- system Python. We only enable the "do not pip install here" guard when that
+ system Python. We only enable the "do not install here" guard when that
marker is present so that tests and non-PEP 668 environments can still
exercise the installer logic.
"""
@@ -144,10 +150,10 @@ def is_externally_managed() -> bool:
def install_python_packages(packages: List[str]) -> bool:
"""
- Install Python packages using pip.
+ Install Python packages using uv pip.
On Ubuntu 22.04+ / Debian 12+ the system Python is externally managed
- (PEP 668) and bare pip installs are blocked. If we detect an externally
+ (PEP 668) and bare installs are blocked. If we detect an externally
managed Python and we are not inside a virtual environment, we surface a
clear message rather than letting pip fail with a confusing error. On
non-PEP 668 environments we allow the install to proceed (the call is
@@ -161,7 +167,7 @@ def install_python_packages(packages: List[str]) -> bool:
"""
if is_externally_managed() and not is_venv():
console.print(
- "[yellow]⚠ pip install skipped: the current Python is not inside a "
+ "[yellow]⚠ package install skipped: the current Python is not inside a "
"virtual environment.[/yellow]\n"
"[dim]On Ubuntu 22.04+ / Debian 12+ the system Python is externally "
"managed (PEP 668).\n"
@@ -172,13 +178,13 @@ def install_python_packages(packages: List[str]) -> bool:
return False
try:
subprocess.run(
- [sys.executable, "-m", "pip", "install"] + packages,
+ ["uv", "pip", "install", "--python", sys.executable] + packages,
check=True,
)
return True
except Exception as exc:
console.print(
- f"[red]✗ pip install failed:[/red] [dim]{exc}[/dim]"
+ f"[red]✗ uv pip install failed:[/red] [dim]{exc}[/dim]"
)
return False
@@ -199,8 +205,8 @@ def check_and_install_dependencies(install_missing: bool = False) -> Dict:
# Check Python dependencies
all_installed, installed_pkgs, missing_pkgs = check_python_dependencies()
- # Check requirements.txt
- required_pkgs = check_requirements_file()
+ # Check pyproject.toml
+ required_pkgs = check_project_dependencies()
results = {
"system": system_deps,
diff --git a/bbackup/management/repo.py b/bbackup/management/repo.py
index 5dbee5d..b6c94d5 100644
--- a/bbackup/management/repo.py
+++ b/bbackup/management/repo.py
@@ -1,19 +1,17 @@
"""
Repository URL management with configurable default and override support.
-The default URL is a placeholder. Set the actual repo URL via:
+The default URL is the public project repository. Override it via:
- Environment variable: BBACKUP_REPO_URL
- Config file: ~/.config/bbackup/management.yaml → repo_url
- - CLI: bbman repo-url --url https://github.com/YOUR_USERNAME/best-backup
+ - CLI: bbman repo-url --url https://github.com/CruxExperts/best-backup
"""
import os
from pathlib import Path
import yaml
-# Placeholder default: override via BBACKUP_REPO_URL env var, management config,
-# or `bbman repo-url --url URL`.
-DEFAULT_REPO_URL = "https://github.com/YOUR_USERNAME/best-backup"
+DEFAULT_REPO_URL = "https://github.com/CruxExperts/best-backup"
def get_repo_url() -> str:
diff --git a/bbackup/management/setup_wizard.py b/bbackup/management/setup_wizard.py
index 3981f35..96adcaf 100644
--- a/bbackup/management/setup_wizard.py
+++ b/bbackup/management/setup_wizard.py
@@ -55,12 +55,20 @@ def check_system_tool(tool: str) -> Tuple[bool, str]:
def check_python_packages() -> Tuple[bool, List[str]]:
"""Check if required Python packages are installed."""
- required = ["rich", "pyyaml", "docker", "click", "paramiko", "cryptography", "requests"]
+ required = {
+ "rich": "rich",
+ "pyyaml": "yaml",
+ "docker": "docker",
+ "click": "click",
+ "paramiko": "paramiko",
+ "cryptography": "cryptography",
+ "requests": "requests",
+ }
missing = []
- for package in required:
+ for package, import_name in required.items():
try:
- __import__(package.replace("-", "_"))
+ __import__(import_name)
except ImportError:
missing.append(package)
diff --git a/bbackup/management/updater.py b/bbackup/management/updater.py
index 098a394..3222a25 100644
--- a/bbackup/management/updater.py
+++ b/bbackup/management/updater.py
@@ -31,8 +31,8 @@ def backup_repository(repo_root: Path, backup_dir: Path) -> bool:
"bbackup",
"bbackup.py",
"bbman.py",
- "setup.py",
- "requirements.txt",
+ "pyproject.toml",
+ "uv.lock",
"config.yaml.example",
]
diff --git a/bbackup/manifest.py b/bbackup/manifest.py
new file mode 100644
index 0000000..355ff2f
--- /dev/null
+++ b/bbackup/manifest.py
@@ -0,0 +1,146 @@
+"""Backup manifest generation and verification."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+from pathlib import Path, PurePosixPath
+from typing import Any, Dict, Iterable, List, Optional
+
+import bbackup
+
+from .config import BackupScope, FilesystemTarget
+
+
+MANIFEST_NAME = "backup_manifest.json"
+MANIFEST_SCHEMA_VERSION = 1
+
+
+def _sha256(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as fh:
+ for chunk in iter(lambda: fh.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def _iter_manifest_files(backup_dir: Path) -> Iterable[Path]:
+ for path in sorted(backup_dir.rglob("*")):
+ if path.is_file() and path.name != MANIFEST_NAME:
+ yield path
+
+
+def _volume_artifacts(backup_dir: Path) -> List[Dict[str, str]]:
+ volumes_dir = backup_dir / "volumes"
+ if not volumes_dir.exists():
+ return []
+
+ artifacts = []
+ for item in sorted(volumes_dir.iterdir()):
+ if item.is_dir():
+ artifacts.append({
+ "name": item.name,
+ "type": "directory",
+ "path": item.relative_to(backup_dir).as_posix(),
+ })
+ elif item.is_file():
+ for suffix in (".tar.gz", ".tar.bz2", ".tar.xz"):
+ if item.name.endswith(suffix):
+ artifacts.append({
+ "name": item.name[:-len(suffix)],
+ "type": "archive",
+ "path": item.relative_to(backup_dir).as_posix(),
+ })
+ break
+ return artifacts
+
+
+def generate_backup_manifest(
+ backup_dir: Path,
+ scope: BackupScope,
+ filesystem_targets: Optional[List[FilesystemTarget]] = None,
+ encryption_mode: str = "disabled",
+ item_results: Optional[Dict[str, Any]] = None,
+ errors: Optional[List[str]] = None,
+) -> Dict[str, Any]:
+ """Write and return a manifest for the current backup directory."""
+ backup_dir = Path(backup_dir)
+ files = []
+ for path in _iter_manifest_files(backup_dir):
+ files.append({
+ "path": path.relative_to(backup_dir).as_posix(),
+ "size": path.stat().st_size,
+ "sha256": _sha256(path),
+ })
+
+ manifest: Dict[str, Any] = {
+ "schema_version": MANIFEST_SCHEMA_VERSION,
+ "backup_name": backup_dir.name,
+ "tool_version": bbackup.__version__,
+ "source_scope": {
+ "containers": scope.containers,
+ "configs": scope.configs,
+ "volumes": scope.volumes,
+ "networks": scope.networks,
+ "filesystems": scope.filesystems,
+ },
+ "filesystem_original_paths": [
+ {"name": target.name, "path": target.path}
+ for target in (filesystem_targets or [])
+ ],
+ "volume_artifacts": _volume_artifacts(backup_dir),
+ "encryption_mode": encryption_mode,
+ "status": "partial" if errors else "complete",
+ "item_results": item_results or {},
+ "errors": errors or [],
+ "files": files,
+ }
+
+ manifest_path = backup_dir / MANIFEST_NAME
+ manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8")
+ return manifest
+
+
+def verify_backup_manifest(backup_dir: Path) -> List[str]:
+ """Return manifest verification errors. Missing manifest means legacy backup."""
+ backup_dir = Path(backup_dir)
+ manifest_path = backup_dir / MANIFEST_NAME
+ if not manifest_path.exists():
+ return []
+
+ try:
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError) as exc:
+ return [f"Invalid backup manifest: {exc}"]
+
+ errors = []
+ expected_paths = set()
+ for entry in manifest.get("files", []):
+ rel_path = entry.get("path")
+ if not rel_path:
+ errors.append("Manifest entry missing path")
+ continue
+ rel_parts = PurePosixPath(rel_path).parts
+ if PurePosixPath(rel_path).is_absolute() or ".." in rel_parts:
+ errors.append(f"Manifest file path escapes backup root: {rel_path}")
+ continue
+ expected_paths.add(rel_path)
+ path = backup_dir / rel_path
+ if not path.exists():
+ errors.append(f"Manifest file missing: {rel_path}")
+ continue
+ expected_size = entry.get("size")
+ if expected_size is not None and path.stat().st_size != expected_size:
+ errors.append(f"Manifest file size mismatch: {rel_path}")
+ continue
+ expected_hash = entry.get("sha256")
+ if expected_hash and _sha256(path) != expected_hash:
+ errors.append(f"Manifest file hash mismatch: {rel_path}")
+ if errors:
+ return errors
+
+ for path in _iter_manifest_files(backup_dir):
+ rel_path = path.relative_to(backup_dir).as_posix()
+ if rel_path not in expected_paths:
+ errors.append(f"Manifest file not listed: {rel_path}")
+ return errors
diff --git a/bbackup/remote.py b/bbackup/remote.py
index 1f6f306..89639e5 100644
--- a/bbackup/remote.py
+++ b/bbackup/remote.py
@@ -42,13 +42,14 @@ def upload_to_rclone(
return False
# Build rclone command
- rclone_path = f"{remote.remote_name}:{remote_path}"
+ final_rclone_path = f"{remote.remote_name}:{remote_path}"
+ partial_rclone_path = f"{final_rclone_path}.partial"
opts = get_effective_rclone_options(self.config, remote)
cmd = [
"rclone",
"copy",
str(local_path),
- rclone_path,
+ partial_rclone_path,
"--progress",
"--stats=1s",
"--transfers",
@@ -76,15 +77,54 @@ def upload_to_rclone(
process.wait()
success = process.returncode == 0
- if success:
- logger.info(f"Successfully uploaded to rclone: {remote_path}")
- else:
+ if not success:
logger.error(f"Failed to upload to rclone: {remote_path}")
- return success
+ self._cleanup_rclone_partial(remote, remote_path, local_path)
+ return False
+
+ move_cmd = [
+ "rclone",
+ "moveto",
+ partial_rclone_path,
+ final_rclone_path,
+ "--transfers",
+ str(opts.transfers),
+ "--checkers",
+ str(opts.checkers),
+ ]
+ move_result = subprocess.run(move_cmd, capture_output=True, text=True, check=False)
+ if move_result.returncode != 0:
+ logger.error(f"Failed to promote rclone partial upload: {move_result.stderr.strip()}")
+ self._cleanup_rclone_partial(remote, remote_path, local_path)
+ return False
+
+ logger.info(f"Successfully uploaded to rclone: {remote_path}")
+ return True
except Exception as e:
logger.error(f"Error uploading to rclone: {e}")
self.console.print(f"[red]Error uploading to rclone: {e}[/red]")
+ self._cleanup_rclone_partial(remote, remote_path, local_path)
return False
+
+ def _cleanup_rclone_partial(self, remote: RemoteStorage, remote_path: str, local_path: Path) -> None:
+ """Best-effort removal of an incomplete rclone upload."""
+ if not remote.remote_name:
+ return
+ try:
+ opts = get_effective_rclone_options(self.config, remote)
+ partial_rclone_path = f"{remote.remote_name}:{remote_path}.partial"
+ cmd = [
+ "rclone",
+ "deletefile" if local_path.is_file() else "purge",
+ partial_rclone_path,
+ "--transfers",
+ str(opts.transfers),
+ "--checkers",
+ str(opts.checkers),
+ ]
+ subprocess.run(cmd, capture_output=True, text=True, check=False)
+ except Exception as e:
+ logger.warning(f"Could not remove rclone partial upload: {e}")
def upload_to_sftp(
self,
@@ -93,10 +133,13 @@ def upload_to_sftp(
remote_path: str,
) -> bool:
"""Upload to remote via SFTP."""
+ partial_remote_path = f"{remote_path}.partial"
+ sftp = None
+ ssh = None
try:
import paramiko
except ImportError:
- self.console.print("[red]Error: paramiko not installed. Install with: pip install paramiko[/red]")
+ self.console.print("[red]Error: paramiko not installed. Run: uv sync[/red]")
return False
try:
@@ -122,7 +165,7 @@ def upload_to_sftp(
# Setup SFTP
sftp = ssh.open_sftp()
-
+
if local_path.is_file():
# remote_path is full destination path (e.g. path/backup_20260304.tar.gz); create parent only (Gap 1)
parts = remote_path.rstrip("/").split("/")
@@ -136,20 +179,44 @@ def upload_to_sftp(
sftp.mkdir(parent)
except IOError:
pass
- sftp.put(str(local_path), remote_path)
+ sftp.put(str(local_path), partial_remote_path)
else:
try:
- sftp.mkdir(remote_path)
+ sftp.mkdir(partial_remote_path)
except IOError:
pass
- self._upload_directory_sftp(sftp, local_path, remote_path)
+ self._upload_directory_sftp(sftp, local_path, partial_remote_path)
+
+ self._rename_sftp(sftp, partial_remote_path, remote_path)
sftp.close()
ssh.close()
return True
except Exception as e:
+ try:
+ if sftp is not None:
+ try:
+ sftp.remove(partial_remote_path)
+ except Exception:
+ sftp.rmdir(partial_remote_path)
+ except Exception:
+ pass
+ try:
+ if sftp is not None:
+ sftp.close()
+ if ssh is not None:
+ ssh.close()
+ except Exception:
+ pass
self.console.print(f"[red]Error uploading to SFTP: {e}[/red]")
return False
+
+ def _rename_sftp(self, sftp, src: str, dest: str) -> None:
+ """Rename an SFTP path, preferring POSIX overwrite semantics when available."""
+ if hasattr(sftp, "posix_rename"):
+ sftp.posix_rename(src, dest)
+ else:
+ sftp.rename(src, dest)
def _upload_directory_sftp(self, sftp, local_dir: Path, remote_dir: str):
"""Recursively upload directory via SFTP."""
@@ -173,11 +240,19 @@ def upload_to_local(
"""Copy to local directory (or single file when backup is solid archive)."""
try:
dest = Path(os.path.expanduser(remote_path))
+ partial_dest = dest.with_name(f"{dest.name}.partial")
+ if partial_dest.exists():
+ if partial_dest.is_dir():
+ shutil.rmtree(partial_dest)
+ else:
+ partial_dest.unlink()
+
if local_path.is_file():
dest.parent.mkdir(parents=True, exist_ok=True)
- shutil.copy2(local_path, dest)
+ shutil.copy2(local_path, partial_dest)
+ os.replace(partial_dest, dest)
elif local_path.is_dir():
- dest.mkdir(parents=True, exist_ok=True)
+ dest.parent.mkdir(parents=True, exist_ok=True)
def ignore_special_files(src, names):
ignored = []
for name in names:
@@ -185,11 +260,23 @@ def ignore_special_files(src, names):
if src_path.is_socket() or (src_path.is_symlink() and not src_path.exists()):
ignored.append(name)
return ignored
+ shutil.copytree(local_path, partial_dest, ignore=ignore_special_files)
if dest.exists():
- shutil.rmtree(dest)
- shutil.copytree(local_path, dest, ignore=ignore_special_files, dirs_exist_ok=True)
+ if dest.is_dir():
+ shutil.rmtree(dest)
+ else:
+ dest.unlink()
+ partial_dest.replace(dest)
return True
except Exception as e:
+ try:
+ if partial_dest.exists():
+ if partial_dest.is_dir():
+ shutil.rmtree(partial_dest)
+ else:
+ partial_dest.unlink()
+ except Exception:
+ pass
self.console.print(f"[red]Error copying to local: {e}[/red]")
return False
@@ -231,8 +318,12 @@ def _list_rclone_backups(self, remote: RemoteStorage) -> List[str]:
try:
cmd = [
"rclone",
- "ls",
+ "lsf",
f"{remote.remote_name}:{remote.path}",
+ "--max-depth",
+ "1",
+ "--format",
+ "p",
"--transfers",
str(opts.transfers),
"--checkers",
@@ -243,10 +334,10 @@ def _list_rclone_backups(self, remote: RemoteStorage) -> List[str]:
# Parse output to get backup names
backups = []
for line in result.stdout.splitlines():
- if line.strip():
- parts = line.split()
- if len(parts) >= 2:
- backups.append(parts[-1])
+ name = line.strip()
+ if not name or "/" in name.rstrip("/"):
+ continue
+ backups.append(name.rstrip("/"))
return backups
except Exception:
pass
diff --git a/bbackup/resources.py b/bbackup/resources.py
new file mode 100644
index 0000000..83c7b88
--- /dev/null
+++ b/bbackup/resources.py
@@ -0,0 +1,20 @@
+"""
+Packaged runtime resource helpers.
+"""
+
+from __future__ import annotations
+
+from importlib import resources
+
+
+DATA_PACKAGE = "bbackup.data"
+
+
+def read_text_resource(name: str) -> str:
+ """Read a bundled text resource."""
+ return resources.files(DATA_PACKAGE).joinpath(name).read_text(encoding="utf-8")
+
+
+def resource_exists(name: str) -> bool:
+ """Return True when a bundled resource is available."""
+ return resources.files(DATA_PACKAGE).joinpath(name).is_file()
diff --git a/bbackup/restore.py b/bbackup/restore.py
index d35ba26..4b80a9c 100644
--- a/bbackup/restore.py
+++ b/bbackup/restore.py
@@ -7,6 +7,8 @@
import os
import shutil
import subprocess
+import tarfile
+import tempfile
from pathlib import Path
from typing import List, Dict, Optional
from datetime import datetime
@@ -17,10 +19,39 @@
from .config import Config
from .logging import get_logger
from .encryption import EncryptionManager
+from .manifest import verify_backup_manifest
logger = get_logger('restore')
+VOLUME_ARCHIVE_SUFFIXES = (".tar.gz", ".tar.bz2", ".tar.xz")
+
+
+def _strip_volume_archive_suffix(filename: str) -> Optional[str]:
+ for suffix in VOLUME_ARCHIVE_SUFFIXES:
+ if filename.endswith(suffix):
+ return filename[:-len(suffix)]
+ return None
+
+
+def list_volume_backup_names(backup_path: Path) -> List[str]:
+ """List volume backup names stored as directories or supported tar archives."""
+ volumes_dir = Path(backup_path) / "volumes"
+ if not volumes_dir.exists():
+ return []
+
+ names = set()
+ for item in volumes_dir.iterdir():
+ if item.is_dir():
+ names.add(item.name)
+ continue
+ if item.is_file():
+ archive_name = _strip_volume_archive_suffix(item.name)
+ if archive_name:
+ names.add(archive_name)
+ return sorted(names)
+
+
class DockerRestore:
"""Docker restore manager."""
@@ -149,63 +180,122 @@ def restore_container_config(self, container_name: str, backup_path: Path, new_n
def restore_volume(self, volume_name: str, backup_path: Path, new_name: Optional[str] = None) -> bool:
"""Restore Docker volume from backup."""
+ temp_extract_dir: Optional[Path] = None
try:
volume_backup_dir = backup_path / "volumes" / volume_name
+ if not volume_backup_dir.exists():
+ archive_path = self._find_volume_archive(backup_path, volume_name)
+ if archive_path is None:
+ return False
+ temp_extract_dir = Path(tempfile.mkdtemp(prefix=f"bbackup_volume_{volume_name}_"))
+ with tarfile.open(archive_path, self._tar_read_mode(archive_path)) as tar:
+ tar.extractall(temp_extract_dir, filter="data")
+ extracted_volume_dir = temp_extract_dir / volume_name
+ volume_backup_dir = extracted_volume_dir if extracted_volume_dir.exists() else temp_extract_dir
+
if not volume_backup_dir.exists():
return False
target_volume_name = new_name or volume_name
-
- # Remove existing volume if it exists
+
+ existing_volume = None
try:
existing_volume = self.client.volumes.get(target_volume_name)
- existing_volume.remove()
except APIError:
pass # Volume doesn't exist
-
- # Create new volume
+
+ staging_volume = None
+ staging_volume_name = f"bbackup_restore_stage_{target_volume_name}_{os.getpid()}"
+ if existing_volume is not None:
+ staging_volume = self.client.volumes.create(name=staging_volume_name)
+ if not self._copy_backup_dir_to_volume(volume_backup_dir, staging_volume_name, volume_name):
+ try:
+ staging_volume.remove()
+ except Exception:
+ pass
+ return False
+ try:
+ staging_volume.remove()
+ except Exception:
+ pass
+ existing_volume.remove()
+
self.client.volumes.create(name=target_volume_name)
-
- # Use temporary container to restore volume data
- temp_container_name = f"bbackup_restore_{target_volume_name}_{os.getpid()}"
-
- try:
- # Create temporary container with volume mounted
- temp_container = self.client.containers.run(
- "alpine:latest",
- command="sleep 3600",
- name=temp_container_name,
- volumes={target_volume_name: {"bind": "/volume_data", "mode": "rw"}},
- detach=True,
- remove=False,
- )
+ return self._copy_backup_dir_to_volume(volume_backup_dir, target_volume_name, volume_name)
- import time
- time.sleep(1)
-
- # Copy backup data to container
- subprocess.run(
- ["docker", "cp", str(volume_backup_dir) + "/.", f"{temp_container_name}:/volume_data/"],
- check=False,
+ except APIError:
+ return False
+ except (tarfile.TarError, OSError) as e:
+ logger.error(f"Failed to read volume archive for {volume_name}: {e}")
+ return False
+ finally:
+ if temp_extract_dir is not None and temp_extract_dir.exists():
+ try:
+ shutil.rmtree(temp_extract_dir)
+ except OSError:
+ pass
+
+ def _copy_backup_dir_to_volume(
+ self,
+ volume_backup_dir: Path,
+ target_volume_name: str,
+ source_volume_name: str,
+ ) -> bool:
+ """Copy backup data into a Docker volume through a temporary container."""
+ temp_container_name = f"bbackup_restore_{target_volume_name}_{os.getpid()}"
+ try:
+ temp_container = self.client.containers.run(
+ "alpine:latest",
+ command="sleep 3600",
+ name=temp_container_name,
+ volumes={target_volume_name: {"bind": "/volume_data", "mode": "rw"}},
+ detach=True,
+ remove=False,
+ )
+
+ import time
+ time.sleep(1)
+
+ copy_result = subprocess.run(
+ ["docker", "cp", str(volume_backup_dir) + "/.", f"{temp_container_name}:/volume_data/"],
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+
+ temp_container.stop()
+ temp_container.remove()
+
+ if copy_result.returncode != 0:
+ logger.error(
+ f"docker cp restore failed for volume {source_volume_name}: {copy_result.stderr.strip()}"
)
-
- # Cleanup
+ return False
+
+ return True
+ except Exception:
+ try:
+ temp_container = self.client.containers.get(temp_container_name)
temp_container.stop()
temp_container.remove()
-
- return True
except Exception:
- # Cleanup on error
- try:
- temp_container = self.client.containers.get(temp_container_name)
- temp_container.stop()
- temp_container.remove()
- except Exception:
- pass
- return False
-
- except APIError:
+ pass
return False
+
+ def _find_volume_archive(self, backup_path: Path, volume_name: str) -> Optional[Path]:
+ volumes_dir = backup_path / "volumes"
+ for suffix in VOLUME_ARCHIVE_SUFFIXES:
+ archive_path = volumes_dir / f"{volume_name}{suffix}"
+ if archive_path.exists():
+ return archive_path
+ return None
+
+ def _tar_read_mode(self, archive_path: Path) -> str:
+ if archive_path.name.endswith(".tar.bz2"):
+ return "r:bz2"
+ if archive_path.name.endswith(".tar.xz"):
+ return "r:xz"
+ return "r:gz"
def restore_network(self, network_name: str, backup_path: Path, new_name: Optional[str] = None) -> bool:
"""Restore network configuration."""
@@ -345,6 +435,16 @@ def restore_backup(
# Decrypt backup dir if encrypted (per-file; no-op for unpacked solid archive)
backup_path = self.decrypt_backup_directory(backup_path)
+ manifest_errors = verify_backup_manifest(backup_path)
+ if manifest_errors:
+ return {
+ "containers": {},
+ "volumes": {},
+ "networks": {},
+ "filesystems": {},
+ "errors": manifest_errors,
+ }
+
rename_map = rename_map or {}
results = {
diff --git a/bbackup/skills.py b/bbackup/skills.py
index 630e859..dd79f1c 100644
--- a/bbackup/skills.py
+++ b/bbackup/skills.py
@@ -329,18 +329,18 @@
"steps": [
{
"command": "bbackup init-encryption --method asymmetric --output json",
- "description": "Generate RSA-4096 or ECDSA keypair. Returns key paths and config snippet.",
+ "description": "Generate RSA-4096 key material. Returns key paths and config snippet.",
"required_flags": [],
"optional_flags": {
"--method": "symmetric, asymmetric, or both",
"--key-path": "directory to save keys (default: ~/.config/bbackup/)",
- "--algorithm": "rsa-4096 or ecdsa-p384",
+ "--algorithm": "rsa-4096",
"--output": "text or json",
"--input-json": "all params as flat JSON object",
},
"valid_values": {
"--method": ["symmetric", "asymmetric", "both"],
- "--algorithm": ["rsa-4096", "ecdsa-p384"],
+ "--algorithm": ["rsa-4096"],
"--output": ["text", "json"],
},
"input_json_schema": {
@@ -354,7 +354,7 @@
"key_path": {"type": "string"},
"algorithm": {
"type": "string",
- "enum": ["rsa-4096", "ecdsa-p384"],
+ "enum": ["rsa-4096"],
"default": "rsa-4096",
},
"output": {"type": "string", "enum": ["text", "json"]},
diff --git a/config.yaml.example b/config.yaml.example
index 73dcc6d..3658ed9 100644
--- a/config.yaml.example
+++ b/config.yaml.example
@@ -5,24 +5,24 @@
backup:
# Default backup directory (local staging area)
local_staging: /tmp/bbackup_staging
-
+
# Create a single compressed tarball (and optionally encrypt it) before upload.
# When true, remotes receive one file instead of many; staging cleanup runs only after at least one successful upload.
# solid_archive: false
-
+
# Compression settings
compression:
enabled: true
level: 6 # 1-9, higher = more compression but slower
format: gzip # gzip, bzip2, xz
-
+
# What to backup by default
default_scope:
containers: true
volumes: true
networks: true
configs: true
-
+
# Backup sets - predefined groups of containers
backup_sets:
production:
@@ -36,7 +36,7 @@ backup:
volumes: true
configs: true
networks: true
-
+
media:
description: "Media stack containers"
containers:
@@ -47,7 +47,7 @@ backup:
scope:
volumes: true
configs: true
-
+
config_only:
description: "Configuration only (no data volumes)"
containers:
@@ -88,7 +88,7 @@ remotes:
key_file: ~/.ssh/backup_key
path: /backups/docker
compression: true
-
+
# Local directory (for testing)
local:
enabled: true
@@ -102,12 +102,12 @@ retention:
daily: 7 # Keep 7 daily backups
weekly: 4 # Keep 4 weekly backups
monthly: 12 # Keep 12 monthly backups
-
+
# Storage quota limits (in GB, 0 = disabled)
max_storage_gb: 0
warning_threshold_percent: 80 # Warn when storage exceeds this %
cleanup_threshold_percent: 90 # Start cleanup when storage exceeds this %
-
+
# Cleanup strategy when quota exceeded
cleanup_strategy: oldest_first # oldest_first, least_important_first
@@ -134,7 +134,7 @@ encryption:
# Use either key_file (local) or key_url (remote)
key_file: ~/.config/bbackup/encryption.key
# OR use URL: key_url: https://raw.githubusercontent.com/user/repo/backup.key
- key_password: null # Optional password for key file encryption
+ key_password: null # Advanced: derive key from password using key_file bytes as salt
algorithm: aes-256-gcm
asymmetric:
# Public key can be file path OR URL (auto-detected)
@@ -144,7 +144,7 @@ encryption:
private_key: ~/.config/bbackup/backup_private.pem
# Private key should always be local file (never URL for security)
private_key_password: null # Optional password for private key
- algorithm: rsa-4096 # rsa-4096, ecdsa-p384
+ algorithm: rsa-4096
verify_key_signature: false # If true, verify key hasn't been tampered with
encrypt_volumes: true
encrypt_configs: true
diff --git a/docs/PUBLISHING_CHECKLIST.md b/docs/PUBLISHING_CHECKLIST.md
new file mode 100644
index 0000000..7508c01
--- /dev/null
+++ b/docs/PUBLISHING_CHECKLIST.md
@@ -0,0 +1,61 @@
+# Publishing checklist
+
+Use this checklist before making the repository public or cutting a release.
+
+## Documentation and community files
+
+- [ ] `README.md` describes the project, install path, quick start, docs, and license.
+- [ ] `INSTALL.md` and `QUICKSTART.md` are current.
+- [ ] `SECURITY.md` explains supported versions and vulnerability reporting.
+- [ ] `.github/CONTRIBUTING.md` documents setup, tests, commits, and PR expectations.
+- [ ] `SUPPORT.md` explains where users should ask questions or get help.
+- [ ] `.github/ISSUE_TEMPLATE/` and `.github/pull_request_template.md` are present.
+- [ ] `LICENSE` has the intended owner and license.
+- [ ] `docs/VERSIONING.md` describes release/version checks and local hook setup.
+
+## Automation
+
+- [ ] `git config core.hooksPath .githooks` is set in the active checkout.
+- [ ] `.githooks/commit-msg` validates conventional commit subjects.
+- [ ] `.githooks/pre-push` runs version sync, generated-doc checks, and whitespace checks.
+- [ ] `.github/workflows/ci.yml` runs syntax, tests, CLI docs, version sync, support doc presence, and publishing readiness checks.
+- [ ] `.github/workflows/release-notes.yml` verifies the tag, builds with `uv build`, and creates a GitHub release from `v*` tags.
+- [ ] CI and release workflows smoke test the installed wheel.
+- [ ] GitHub workflow actions are on current supported major versions.
+- [ ] GitHub workflows declare least-privilege `permissions`.
+- [ ] CI installs dependencies with `uv sync --locked` and runs commands with `uv run`.
+
+## Version and release state
+
+- [ ] `VERSION` contains the intended semantic version.
+- [ ] `bbackup/__init__.py`, `pyproject.toml`, README badge, `CHANGELOG.md`, and generated CLI skills docs match `VERSION`.
+- [ ] `pyproject.toml` requires Python `>=3.12`.
+- [ ] `.python-version` pins the local baseline to `3.12`.
+- [ ] `uv.lock` is present and current.
+- [ ] `CHANGELOG.md` has a dated section for the release.
+- [ ] `uv sync --locked` passes.
+- [ ] `uv run python scripts/check_version_sync.py` passes.
+- [ ] `uv run python scripts/check_publishing_ready.py` passes.
+- [ ] `uv run python scripts/generate_cli_skills.py --check` passes.
+
+## Scrub
+
+- [ ] No secrets, API keys, tokens, private keys, `.env` files, or credentials are tracked.
+- [ ] No local backup archives, logs, caches, or runtime state are tracked.
+- [ ] No machine-specific install paths are documented as required paths.
+- [ ] Public URLs point to the intended GitHub repository.
+- [ ] Localsetup runtime state and agent run ledgers remain untracked.
+
+## Validation
+
+```bash
+uv sync --locked
+uv run python scripts/check_version_sync.py
+uv run python scripts/check_publishing_ready.py
+uv run python scripts/generate_cli_skills.py --check
+uv run python -m py_compile bbackup.py bbman.py bbackup/*.py bbackup/data/*.py bbackup/management/*.py scripts/*.py
+uv build
+uv run python scripts/smoke_installed_artifact.py
+uv run pytest
+git diff --check
+```
diff --git a/docs/README.md b/docs/README.md
index 33a9071..133f33f 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -12,6 +12,10 @@
| [management.md](management.md) | Full `bbman` command reference |
| [encryption.md](encryption.md) | Encryption setup, key hosting, multi-server deployment |
| [cli-skills.md](cli-skills.md) | CLI skills catalog for all core `bbackup` and `bbman` commands |
+| [VERSIONING.md](VERSIONING.md) | Release version source of truth, hook setup, and validation commands |
+| [PUBLISHING_CHECKLIST.md](PUBLISHING_CHECKLIST.md) | GitHub publishing and release readiness checklist |
+| [assets/README.md](assets/README.md) | Documentation image inventory and visual standards |
+| [prompts/bootstrap-planning-agent.md](prompts/bootstrap-planning-agent.md) | Planning handoff for Codex-maintenance work |
---
@@ -23,7 +27,7 @@ Back to [README.md](../README.md).
Slavic Kozyuk
-© 2026 Crux Experts LLC — MIT License
+© 2026 Crux Experts LLC — MIT License
diff --git a/docs/VERSIONING.md b/docs/VERSIONING.md
new file mode 100644
index 0000000..fc55762
--- /dev/null
+++ b/docs/VERSIONING.md
@@ -0,0 +1,74 @@
+# Versioning
+
+`VERSION` is the canonical release version for this repository. Release-facing
+references must match it before publishing:
+
+- `bbackup/__init__.py` `__version__`
+- `pyproject.toml` `version`
+- `pyproject.toml` `requires-python` set to `>=3.12`
+- README version badge
+- `CHANGELOG.md` current release header and `[Unreleased]` compare target
+- generated CLI skills docs
+
+Run the version sync check:
+
+```bash
+uv run python scripts/check_version_sync.py
+```
+
+Regenerate CLI skills docs after command metadata or package version changes:
+
+```bash
+uv run python scripts/generate_cli_skills.py
+uv run python scripts/generate_cli_skills.py --check
+```
+
+Refresh the locked dependency graph only after dependency or supported Python
+version changes:
+
+```bash
+uv lock
+uv sync --locked
+```
+
+## Commit and hook setup
+
+This repo includes local Git hooks in `.githooks/`. Git does not enable tracked
+hook directories automatically after clone, so enable them once per checkout:
+
+```bash
+git config core.hooksPath .githooks
+```
+
+The hooks enforce conventional commit subjects and run release-readiness checks
+before push.
+
+## Release flow
+
+1. Update `VERSION`.
+2. Sync the version references listed above.
+3. Run `uv lock` if dependencies or supported Python versions changed.
+4. Add a dated `CHANGELOG.md` section for the release.
+5. Run:
+
+```bash
+uv sync --locked
+uv run python scripts/check_version_sync.py
+uv run python scripts/check_publishing_ready.py
+uv run python scripts/generate_cli_skills.py --check
+uv run python -m py_compile bbackup.py bbman.py bbackup/*.py bbackup/data/*.py bbackup/management/*.py scripts/*.py
+uv build
+uv run python scripts/smoke_installed_artifact.py
+uv run pytest
+git diff --check
+```
+
+6. Commit with a conventional commit message.
+7. Tag the release as `vX.Y.Z` and push the tag after CI is green.
+
+Pushing a tag that matches `v*` runs `.github/workflows/release-notes.yml`.
+The release job verifies that the tag matches `VERSION`, runs the version and
+publishing checks, runs Ruff, py_compile, generated-doc checks, pytest, builds
+release artifacts with `uv build`, smoke tests the built wheel, and creates a
+GitHub release from the matching `CHANGELOG.md` section. The release job fails
+if the matching changelog section is missing.
diff --git a/docs/architecture.md b/docs/architecture.md
index 7c99826..2f6c05a 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -8,7 +8,7 @@
| Component | Library / tool | Version |
|---|---|---|
-| Language | Python | 3.10+ |
+| Language | Python | 3.12+ |
| CLI framework | Click | 8.1.7+ |
| Terminal UI | Rich | 13.7.0+ |
| Docker integration | docker-py SDK | 7.0.0+ |
@@ -21,6 +21,19 @@
---
+## Documentation visuals
+
+GitHub-facing documentation uses Markdown-native visuals wherever possible:
+
+- Use Mermaid fenced code blocks for flow charts and release/readiness diagrams so charts render directly in GitHub pull requests, releases, and Markdown files.
+- Keep each diagram self-describing with clear node labels and avoid depending on color alone.
+- Store bitmap assets under `docs/assets/` and reference them with relative paths plus specific alt text.
+- Keep generated images free of embedded text so README headings, captions, and links remain searchable and accessible.
+
+The README hero image is a bitmap overview of the backup pipeline. The canonical system flow remains the Mermaid diagram in `README.md`, because it is reviewable in diffs and easier to update when behavior changes.
+
+---
+
## Backup strategy
The tool uses two separate mechanisms depending on what it is backing up.
@@ -93,6 +106,7 @@ Orchestrates the full backup workflow:
init → select items → prepare staging dir
→ backup configs → backup volumes → backup networks
→ backup filesystem paths (if filesystem_targets provided)
+ → write backup_manifest.json with file sizes and SHA-256 hashes
→ [if solid_archive] create_solid_archive → optionally encrypt whole file → upload file → cleanup staging only after success
→ [else] encrypt dir (if enabled) → upload dir to remotes
→ rotate old backups
@@ -112,11 +126,15 @@ Backs up arbitrary host filesystem paths and directory trees using rsync directl
### `bbackup/archive.py`
-Solid-archive support: creates a single compressed tarball from a backup directory and optionally encrypts the whole file. Exposes `create_solid_archive()`, `unpack_solid_archive()`, and naming helpers `is_solid_archive_name()`, `strip_solid_archive_suffix()` used by list/rotation/restore. When solid archive is enabled, the backup flow tars the staging dir, compresses it (using `backup.compression`), and optionally encrypts the result so remotes receive one file. Staging cleanup runs only after at least one successful upload.
+Solid-archive support: creates a single compressed tarball from a backup directory and optionally encrypts the whole file. Exposes `create_solid_archive()`, `unpack_solid_archive()`, and naming helpers `is_solid_archive_name()`, `strip_solid_archive_suffix()` used by list/rotation/restore. Archive creation writes `.partial` files and promotes them to the final path only after tar/encryption succeeds. When solid archive encryption succeeds, plaintext staging is removed before upload.
+
+### `bbackup/manifest.py`
+
+Every non-cancelled backup writes `backup_manifest.json` before encryption/upload. The manifest records schema version, backup name, tool version, requested source scope, filesystem source paths, volume artifact names, encryption mode, item results, errors, file sizes, and SHA-256 hashes. Restore verifies the manifest when present and fails before mutation on missing, changed, unlisted, or escaping file paths. Backups without a manifest are treated as legacy backups.
### `bbackup/restore.py`
-Reads a backup directory or a solid archive file and restores containers, volumes, networks, and filesystem paths. When the backup path is a file (e.g. `.tar.gz` or `.tar.gz.enc`), unpacks to a temp dir, runs the same restore logic, then removes the temp dir. Supports renaming on restore (`--rename old:new`). Handles decryption (per-dir or whole-archive for solid archives) before restore.
+Reads a backup directory or a solid archive file and restores containers, volumes, networks, and filesystem paths. When the backup path is a file (e.g. `.tar.gz` or `.tar.gz.enc`), unpacks to a temp dir, runs the same restore logic, then removes the temp dir. Supports renaming on restore (`--rename old:new`). Handles decryption (per-dir or whole-archive for solid archives) and manifest verification before restore. Existing Docker volume replacement uses a staging-volume copy preflight so a failed first copy leaves the existing volume untouched; Docker does not support atomic volume rename, so final replacement is still a destructive operation after preflight.
### `bbackup/tui.py`
@@ -127,7 +145,7 @@ Two main classes:
### `bbackup/remote.py`
-Abstracts three upload targets behind a common interface: local filesystem (shutil), rclone (subprocess), and SFTP (paramiko). Each remote is tried independently so one failure does not abort others. Upload progress feeds into `BackupStatus`.
+Abstracts three upload targets behind a common interface: local filesystem (shutil), rclone (subprocess), and SFTP (paramiko). Each remote is tried independently so one failure does not abort others. Uploads write to `.partial` destinations first and promote to final paths only after the copy succeeds. Upload progress feeds into `BackupStatus`.
### `bbackup/rotation.py`
@@ -213,6 +231,26 @@ For remotes with `type: rclone`, you can tune transfer concurrency so uploads us
---
+## Release readiness flow
+
+```mermaid
+flowchart LR
+ version[VERSION
single source of truth]
+ sync[Version sync check
package • README • CLI docs]
+ build[Build artifacts
wheel • sdist]
+ smoke[Installed smoke test
CLI entry points]
+ tests[Test suite
pytest • py_compile]
+ publish[GitHub release prep
PR • tag after CI]
+
+ version --> sync
+ sync --> build
+ build --> smoke
+ smoke --> tests
+ tests --> publish
+```
+
+---
+
## Known gaps
- `create_metadata_archive()` exists in `docker_backup.py` but is not yet called from `backup_runner.py`. The method produces a compressed tar of config and network metadata. Wiring it in requires adding a call after all item backups complete and before `encrypt_backup_directory()`.
@@ -247,8 +285,8 @@ best-backup/
├── bbackup.py # bbackup entry point
├── bbman.py # bbman entry point
├── config.yaml.example
-├── requirements.txt
-└── setup.py
+├── pyproject.toml
+└── uv.lock
```
---
@@ -261,7 +299,7 @@ Back to [README.md](../README.md).
Slavic Kozyuk
-© 2026 Crux Experts LLC — MIT License
+© 2026 Crux Experts LLC — MIT License
diff --git a/docs/assets/README.md b/docs/assets/README.md
new file mode 100644
index 0000000..4680257
--- /dev/null
+++ b/docs/assets/README.md
@@ -0,0 +1,9 @@
+# Documentation Assets
+
+This directory stores GitHub-facing bitmap assets referenced by the project documentation.
+
+| File | Purpose | Notes |
+|---|---|---|
+| `bbackup-hero.png` | README hero image showing the Docker, filesystem, database, encryption, verification, and remote-storage backup pipeline | Generated image, no embedded text, referenced with descriptive alt text from `README.md` |
+
+Prefer Mermaid diagrams in Markdown for charts and process flows. Use bitmap assets when a visual overview improves first-read comprehension or when a diagram benefits from richer UI-style composition.
diff --git a/docs/assets/bbackup-hero.png b/docs/assets/bbackup-hero.png
new file mode 100644
index 0000000..dec0c22
Binary files /dev/null and b/docs/assets/bbackup-hero.png differ
diff --git a/docs/cli-skills-index.json b/docs/cli-skills-index.json
index 025fb69..fb222d6 100644
--- a/docs/cli-skills-index.json
+++ b/docs/cli-skills-index.json
@@ -87,4 +87,4 @@
"end": 761,
"start": 731
}
-}
\ No newline at end of file
+}
diff --git a/docs/cli-skills.md b/docs/cli-skills.md
index 3e99660..d9c23d1 100644
--- a/docs/cli-skills.md
+++ b/docs/cli-skills.md
@@ -1,6 +1,6 @@
# CLI skills catalog
-> Generated from the bbackup/bbman CLI metadata. Version: 1.7.0. This catalog is authoritative for this version.
+> Generated from the bbackup/bbman CLI metadata. Version: 1.8.0. This catalog is authoritative for this version.
## bbackup
@@ -112,7 +112,7 @@ Generate symmetric and/or asymmetric keys for encrypting backups at rest and ret
|---|---|:---:|---|---|
| `--method` | `string` | no | `'symmetric'` | Encryption method to use. |
| `--key-path` | `path` | no | `` | Directory to save key(s) (default: ~/.config/bbackup/). |
-| `--password` | `string` | no | `` | Password for key encryption (optional). |
+| `--password` | `string` | no | `` | Not currently supported for generated keys; command fails if provided. |
| `--algorithm` | `string` | no | `'rsa-4096'` | Algorithm for asymmetric keys. |
| `--upload-github` | `bool` | no | `False` | Remind about uploading public key to GitHub. |
| `--skills` | `bool` | no | `False` | Show skills documentation for this command and exit. |
@@ -758,4 +758,3 @@ Validate config.yaml and report backup sets, remotes, and encryption status.
```bash
bbman validate-config --input-json '{"output":"json"}' --output json
```
-
diff --git a/docs/encryption.md b/docs/encryption.md
index a7deae1..ea4203e 100644
--- a/docs/encryption.md
+++ b/docs/encryption.md
@@ -12,7 +12,9 @@ Two modes are available:
**Symmetric (AES-256-GCM):** One key does both encryption and decryption. Simpler to set up. Good for single-server setups where the same machine backs up and restores.
-**Asymmetric (RSA-4096 or ECDSA P-384):** A public key encrypts, a private key decrypts. The public key can be shared or posted publicly. The private key stays on the restore machine only. This is the right choice when backup servers and restore servers are different machines, or when you want to separate the ability to create backups from the ability to read them.
+**Asymmetric (RSA-4096):** A public key encrypts, a private key decrypts. The public key can be shared or posted publicly. The private key stays on the restore machine only. This is the right choice when backup servers and restore servers are different machines, or when you want to separate the ability to create backups from the ability to read them.
+
+When encryption succeeds, bbackup removes the plaintext staging directory and keeps the encrypted directory or encrypted solid archive as the local artifact. If encryption is enabled but fails, backup upload is aborted rather than falling back to plaintext.
---
@@ -208,7 +210,7 @@ Back to [README.md](../README.md).
Slavic Kozyuk
-© 2026 Crux Experts LLC — MIT License
+© 2026 Crux Experts LLC — MIT License
diff --git a/docs/management.md b/docs/management.md
index 3d17c8c..8c05849 100644
--- a/docs/management.md
+++ b/docs/management.md
@@ -42,7 +42,7 @@ bbman health --output json # Machine-readable result
Checks:
- Docker daemon is running and your user has socket access
- System tools: `rsync`, `tar`, `rclone`
-- Python dependencies match `requirements.txt`
+- Python dependencies match `pyproject.toml`
- Config file parses without errors
- Staging and log directories are writable
@@ -166,7 +166,7 @@ Manage the repository URL used for update checks and downloads.
```bash
bbman repo-url # Show current URL and its source
-bbman repo-url --url https://github.com/YOUR_USERNAME/best-backup
+bbman repo-url --url https://github.com/CruxExperts/best-backup
bbman repo-url --output json
```
@@ -255,7 +255,7 @@ See [README.md](../README.md#agent-integration) for exit code reference and the
`~/.config/bbackup/management.yaml` controls wrapper behavior:
```yaml
-repo_url: "https://github.com/YOUR_USERNAME/best-backup"
+repo_url: "https://github.com/CruxExperts/best-backup"
auto_check_updates: true
check_interval_days: 7
auto_setup_on_first_run: true
@@ -351,7 +351,7 @@ Back to [README.md](../README.md).
Slavic Kozyuk
-© 2026 Crux Experts LLC — MIT License
+© 2026 Crux Experts LLC — MIT License
diff --git a/docs/prompts/bootstrap-planning-agent.md b/docs/prompts/bootstrap-planning-agent.md
new file mode 100644
index 0000000..5dd70c7
--- /dev/null
+++ b/docs/prompts/bootstrap-planning-agent.md
@@ -0,0 +1,44 @@
+# Bootstrap Planning Agent Prompt
+
+Use this prompt to hand `/mnt/data/devzone/bbackup` to a planning agent.
+
+```text
+You are the planning agent for /mnt/data/devzone/bbackup.
+
+Objective:
+Turn this checkout into the working maintenance repo for bbackup and Codex-related
+development work without implementing domain changes until the user accepts a
+plan.
+
+Repository context:
+- Repo path: /mnt/data/devzone/bbackup
+- GitHub remote: https://github.com/CruxExperts/best-backup.git
+- Primary package: bbackup/
+- CLI entry points: bbackup.py, bbman.py, bbackup/cli.py, bbackup/bbman_entry.py
+- Repo policy: AGENTS.md
+- Localsetup Codex adapter should be managed by native localsetup commands.
+
+Planning task:
+1. Read AGENTS.md, README.md, INSTALL.md, QUICKSTART.md, and docs/README.md.
+2. Inspect Localsetup adapter status with:
+ localsetup adapters --target-directory /mnt/data/devzone/bbackup --platforms codex
+3. Inspect git status.
+4. Produce a concise implementation plan for the requested bbackup maintenance
+ work.
+5. Stop for user confirmation before broad domain implementation or risky
+ service/backup operations.
+
+Validation commands to include in the plan:
+- git status --short --ignored
+- uv run python -m py_compile bbackup.py bbman.py bbackup/*.py bbackup/data/*.py bbackup/management/*.py scripts/*.py
+- uv run pytest
+- localsetup adapters --target-directory /mnt/data/devzone/bbackup --platforms codex
+- localsetup doctor --target-directory /mnt/data/devzone/bbackup --global-preset core --repo-preset core --platforms codex --dependency-mode uv-sync --json
+
+Output format:
+- Start with assumptions.
+- Then list the plan in 5-8 numbered steps.
+- Include explicit acceptance criteria.
+- Include commands the implementation agent should run.
+- Call out anything that requires human confirmation.
+```
diff --git a/docs/tests/sandbox-test-report.md b/docs/tests/sandbox-test-report.md
deleted file mode 100644
index 6835ad2..0000000
--- a/docs/tests/sandbox-test-report.md
+++ /dev/null
@@ -1,143 +0,0 @@
-# Sandbox Test Report
-
-**Date:** 2026-02-26
-**Python:** 3.12.7
-**pytest:** 9.0.2
-**Result:** 197 passed, 2 skipped, 0 failed
-
----
-
-## Test files
-
-| File | Tests | Coverage target |
-|------|-------|-----------------|
-| `tests/test_config.py` | 20 | `bbackup/config.py` (99%) |
-| `tests/test_rotation.py` | 27 | `bbackup/rotation.py` (68%) |
-| `tests/test_encryption.py` | 20 | `bbackup/encryption.py` (30%) |
-| `tests/test_remote.py` | 13 | `bbackup/remote.py` (42%) |
-| `tests/test_cli.py` | 27 | `bbackup/cli.py`, entry points |
-| `tests/test_maintenance_stamp.py` | 33 | `maintenance/stamp.py` (71%) |
-| `tests/test_maintenance_bump_version.py` | 37 | `maintenance/bump_version.py` (67%) |
-| `tests/test_maintenance_check_docs.py` | 20 | `maintenance/check_docs.py` (65%) |
-
----
-
-## Coverage summary
-
-| Module | Stmts | Miss | Cover |
-|--------|-------|------|-------|
-| bbackup/__init__.py | 2 | 0 | 100% |
-| bbackup/config.py | 109 | 1 | 99% |
-| bbackup/logging.py | 30 | 1 | 97% |
-| bbackup/rotation.py | 121 | 39 | 68% |
-| bbackup/remote.py | 144 | 83 | 42% |
-| bbackup/encryption.py | 363 | 254 | 30% |
-| bbackup/cli.py | 385 | 312 | 19% |
-| bbackup/backup_runner.py | 235 | 219 | 7% |
-| bbackup/docker_backup.py | 225 | 201 | 11% |
-| bbackup/restore.py | 198 | 178 | 10% |
-| bbackup/tui.py | 334 | 304 | 9% |
-| bbackup/management/__init__.py | 7 | 0 | 100% |
-| maintenance/stamp.py | 104 | 30 | 71% |
-| maintenance/bump_version.py | 160 | 53 | 67% |
-| maintenance/check_docs.py | 96 | 34 | 65% |
-| maintenance/release.py | 172 | 172 | 0% |
-| **TOTAL** | **3680** | **2790** | **24%** |
-
-HTML report: `docs/test-coverage/index.html`
-
----
-
-## What is tested
-
-### `bbackup/config.py` (99%)
-
-- All dataclass defaults (BackupScope, RetentionPolicy, IncrementalSettings, EncryptionSettings, RemoteStorage)
-- Config with no file path: loads defaults, correct staging dir, empty backup sets / remotes
-- Config from full YAML: retention, scope, incremental, backup sets (name, description, containers, scope), remotes, enabled remotes filter, staging dir, get_backup_set by name
-- Error cases: malformed YAML raises ValueError, empty YAML loads defaults, partial retention fills remaining defaults, disabled remote excluded from enabled list
-
-### `bbackup/rotation.py` (68%)
-
-- Age categorization: today, yesterday, 6d (daily), 7d and 20d (weekly), 30d and 90d (monthly)
-- `should_keep_backup`: recent, Sunday weekly, first-of-month monthly
-- `_parse_backup_date`: standard format, extra prefix parts, unparseable, empty string, invalid date (month 13)
-- `filter_backups_by_retention`: daily limit cap, excess deleted, keep + delete == total, empty list, unparseable names excluded
-- Storage quota: disabled when max 0, enabled when max set, no warning on low usage
-- `_calculate_local_storage`: empty dir, single-level files, nested files, nonexistent path
-
-### `bbackup/encryption.py` (30%)
-
-- EncryptionManager construction: disabled config, symmetric with no key / missing key file / valid key file, asymmetric with no keys
-- AES-256-GCM: encrypt/decrypt roundtrip, nonce uniqueness, wrong-key failure (InvalidTag), tampered ciphertext, AAD enforcement, 32-byte key requirement
-- File encrypt/decrypt roundtrip (via `encrypt_file` / `decrypt_file`, skipped if not implemented)
-- RSA 2048-bit key generation, public key extraction, PEM serialization/deserialization roundtrip
-- URL detection helpers: `_is_url` (HTTP, HTTPS, file path), `_is_github_shortcut`
-
-### `bbackup/remote.py` (42%)
-
-- `upload_to_local`: single file, directory (recursive), creates missing dest, overwrites existing
-- `_list_local_backups`: lists directories only, empty dir, nonexistent path
-- `list_backups` dispatch: local -> `_list_local_backups`, SFTP -> empty
-- `upload_backup` dispatch: local type, unknown type returns False, rclone binary check, rclone without remote_name
-
-### `bbackup/cli.py` (19%)
-
-- All modules importable: bbackup, config, cli, backup_runner, docker_backup, restore, remote, rotation, tui, encryption, management
-- Package attributes: `__version__` (semver), `__author__` (str)
-- `--help` for: main, backup, restore, list-containers, init-config, list-backups
-- `--version` flag
-- All 5 expected commands registered (backup, restore, list-containers, init-config, list-backups)
-- `bbackup.py` entry point: `--help` and `--version` via subprocess
-- Management API callables: run_health_check, is_first_run, check_for_updates, run_setup_wizard
-
-### `maintenance/stamp.py` (71%)
-
-- `load_config`: valid file, missing file raises SystemExit, custom path via PROJECT_YAML
-- `build_footer`: contains author, company name, company URL, year, license, FOOTER sentinels, `align="center"`, license URL links to repo LICENSE file
-- `stamp_file`: stamps new file, idempotent (second stamp returns "unchanged"), single sentinel block after two stamps, dry_run no-op, missing file returns "skipped", replaces outdated footer, preserves content above footer
-- `sync_code_files`: updates LICENSE copyright line, dry_run skips write, missing LICENSE returns "skipped"
-- `stamp_docs`: stamps multiple files in one call, skips missing target
-
-### `maintenance/bump_version.py` (67%)
-
-- `parse_semver`: basic, zeros, large numbers, missing patch raises, non-numeric raises
-- `bump`: patch, minor (resets patch), major (resets minor+patch), on zero inputs
-- `determine_bump_type`: feat->minor, feat(scope)->minor, fix->patch, docs->patch, chore->patch, BREAKING CHANGE->major, feat!->major, mixed feat+fix->minor, empty->patch, unrecognized->patch
-- `read_version` / `write_version` / round-trip
-- `sync_version_in_file`: updates `__version__`, already-current no-op, no pattern match, missing file, package.json style
-- `generate_changelog_entry`: version header, today date, feat->Added, fix->Fixed, docs->Documentation, chore->Maintenance, bump commit excluded, empty messages fallback, subject capitalized
-- `prepend_changelog`: new section prepended, dry_run no-op, missing CHANGELOG skips gracefully
-
-### `maintenance/check_docs.py` (65%)
-
-- `match_glob`: exact, wildcard, recursive wildcard, no match, multiple patterns, empty list
-- `check_internal_links`: no links, valid relative link, broken relative link, HTTP links skipped, anchor links skipped, mixed links, link with fragment, missing doc file
-- `DocCheckResult`: default ok=True, can hold broken_links, can hold docs_to_review
-- `run()` (with patched `get_changed_files`): no changed files is ok, detects broken link, flags stale docs, all clean returns ok
-
----
-
-## Skipped tests (2)
-
-Both skipped because private helpers (`_encrypt_data`, `_decrypt_data`) are not exposed on `EncryptionManager`. The corresponding `encrypt_file`/`decrypt_file` roundtrip test passed. No action needed.
-
----
-
-## Coverage gaps (what is NOT tested)
-
-These modules require a live Docker daemon and cannot be meaningfully unit-tested without integration fixtures:
-
-- `bbackup/backup_runner.py` (7%) - Docker container/volume orchestration
-- `bbackup/docker_backup.py` (11%) - Docker API calls
-- `bbackup/restore.py` (10%) - Docker container/volume restore
-- `bbackup/tui.py` (9%) - Rich terminal UI rendering
-- `maintenance/release.py` (0%) - Full release workflow (git operations, real filesystem)
-
-These would require either a Docker-in-Docker test environment or extensive mocking of the Docker SDK. Recommended next step: integration test suite using `pytest-docker` or a dedicated test container.
-
----
-
-## Findings
-
-No bugs found. All tested logic behaves as designed. One style note: `upload_backup` in `remote.py` has a duplicated docstring (line 196-197) which is cosmetic only.
diff --git a/project.yaml b/project.yaml
index c795de9..8d79181 100644
--- a/project.yaml
+++ b/project.yaml
@@ -1,12 +1,11 @@
# project.yaml
-# Single source of truth for project identity.
-# Consumed by maintenance/stamp.py to sync footers, code files, and GitHub metadata.
-# Update this file, then run: python maintenance/stamp.py
+# Project identity metadata for docs, packaging, and maintainer automation.
+# Keep this file aligned with README.md, pyproject.toml, and GitHub metadata.
project:
name: bbackup
description: "Docker Backup Tool with Rich TUI"
- repository: "https://github.com/cptnfren/best-backup"
+ repository: "https://github.com/CruxExperts/best-backup"
author:
name: "Slavic Kozyuk"
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..64ef7ca
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,80 @@
+[build-system]
+requires = ["setuptools>=70", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "bbackup"
+version = "1.8.0"
+description = "Docker Backup Tool with Rich TUI"
+readme = "README.md"
+requires-python = ">=3.12"
+license = "MIT"
+authors = [
+ { name = "Slavic Kozyuk / Crux Experts LLC" },
+]
+keywords = [
+ "docker",
+ "backup",
+ "containers",
+ "volumes",
+ "tui",
+ "rsync",
+ "encryption",
+ "restore",
+]
+classifiers = [
+ "Development Status :: 4 - Beta",
+ "Intended Audience :: System Administrators",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Programming Language :: Python :: 3.14",
+ "Topic :: System :: Archiving :: Backup",
+]
+dependencies = [
+ "rich>=13.7.0",
+ "pyyaml>=6.0.1",
+ "docker>=7.0.0",
+ "click>=8.1.7",
+ "paramiko>=3.4.0",
+ "cryptography>=41.0.0",
+ "requests>=2.31.0",
+]
+
+[project.optional-dependencies]
+management = [
+ "gitpython>=3.1.0",
+]
+
+[project.urls]
+Homepage = "https://github.com/CruxExperts/best-backup"
+Repository = "https://github.com/CruxExperts/best-backup"
+Issues = "https://github.com/CruxExperts/best-backup/issues"
+
+[project.scripts]
+bbackup = "bbackup.cli:cli"
+bbman = "bbackup.bbman_entry:cli"
+
+[dependency-groups]
+dev = [
+ "pytest>=8.0",
+ "pytest-cov>=5.0",
+ "pytest-mock>=3.14",
+ "ruff>=0.8.0",
+]
+
+[tool.setuptools.packages.find]
+include = ["bbackup*"]
+
+[tool.setuptools.package-data]
+"bbackup.data" = ["*.example", "*.md", "*.json"]
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+addopts = "--tb=short -q"
+markers = [
+ "integration: marks tests requiring a live Docker daemon (deselect with -m 'not integration')",
+]
+
+[tool.ruff]
+target-version = "py312"
diff --git a/pytest.ini b/pytest.ini
deleted file mode 100644
index 0d8b509..0000000
--- a/pytest.ini
+++ /dev/null
@@ -1,5 +0,0 @@
-[pytest]
-testpaths = tests
-addopts = --tb=short -q
-markers =
- integration: marks tests requiring a live Docker daemon (deselect with -m "not integration")
diff --git a/requirements-dev.txt b/requirements-dev.txt
deleted file mode 100644
index 9e6bdf3..0000000
--- a/requirements-dev.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-pytest>=8.0
-pytest-cov>=5.0
-pytest-mock>=3.14
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index e70fbc3..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,7 +0,0 @@
-rich>=13.7.0
-pyyaml>=6.0.1
-docker>=7.0.0
-click>=8.1.7
-paramiko>=3.4.0
-cryptography>=41.0.0
-requests>=2.31.0
diff --git a/scripts/README.md b/scripts/README.md
index 87c9db1..6b971ac 100644
--- a/scripts/README.md
+++ b/scripts/README.md
@@ -11,9 +11,9 @@
Generates a test filesystem with 13,000+ files across realistic directory structures (archives, projects, documents, media). Useful for testing backup, restore, and incremental backup behavior without touching real data.
```bash
-python scripts/create_sandbox.py --output /tmp/bbackup_sandbox
-python scripts/create_sandbox.py --output /tmp/bbackup_sandbox --quick # Fewer files, faster
-python scripts/create_sandbox.py --output /tmp/bbackup_sandbox --file-count 5000
+uv run python scripts/create_sandbox.py --output /tmp/bbackup_sandbox
+uv run python scripts/create_sandbox.py --output /tmp/bbackup_sandbox --quick # Fewer files, faster
+uv run python scripts/create_sandbox.py --output /tmp/bbackup_sandbox --file-count 5000
```
Options:
@@ -35,7 +35,7 @@ Options:
Runs backup scenarios against the sandbox created by `create_sandbox.py` and logs results.
```bash
-python scripts/test_sandbox_backups.py
+uv run python scripts/test_sandbox_backups.py
```
---
@@ -88,7 +88,7 @@ Back to [README.md](../README.md).
Slavic Kozyuk
-© 2026 Crux Experts LLC — MIT License
+© 2026 Crux Experts LLC — MIT License
diff --git a/scripts/check_publishing_ready.py b/scripts/check_publishing_ready.py
new file mode 100644
index 0000000..7d2f760
--- /dev/null
+++ b/scripts/check_publishing_ready.py
@@ -0,0 +1,199 @@
+"""
+Check GitHub publishing readiness surfaces that are easy to drift.
+"""
+
+from __future__ import annotations
+
+import re
+import subprocess
+import sys
+from pathlib import Path
+
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+CANONICAL_REPO = "github.com/CruxExperts/best-backup"
+LEGACY_REPO = "github.com/cptnfren/best-backup"
+PY_COMPILE_COMMAND = (
+ "uv run python -m py_compile bbackup.py bbman.py bbackup/*.py "
+ "bbackup/data/*.py bbackup/management/*.py scripts/*.py"
+)
+PY_COMPILE_TARGETS = (
+ "bbackup.py",
+ "bbman.py",
+ "bbackup/*.py",
+ "bbackup/data/*.py",
+ "bbackup/management/*.py",
+ "scripts/*.py",
+)
+
+
+EXPECTED_ACTIONS = {
+ ".github/workflows/ci.yml": [
+ "actions/checkout@v6",
+ "actions/setup-python@v6",
+ "actions/upload-artifact@v7",
+ ],
+ ".github/workflows/release-notes.yml": [
+ "actions/checkout@v6",
+ "actions/setup-python@v6",
+ "softprops/action-gh-release@v3",
+ ],
+ ".github/workflows/stale.yml": [
+ "actions/stale@v10",
+ ],
+}
+
+
+PUBLIC_SCAN_ROOTS = [
+ ".github",
+ "bbackup",
+ "docs",
+ "scripts/README.md",
+ "README.md",
+ "INSTALL.md",
+ "QUICKSTART.md",
+ "SECURITY.md",
+ "SUPPORT.md",
+ "CHANGELOG.md",
+ "CONTRIBUTING.md",
+ "pyproject.toml",
+ ".python-version",
+ "project.yaml",
+]
+
+PUBLIC_EXTENSIONS = {".md", ".py", ".yaml", ".yml", ".toml", ".txt"}
+
+
+def read(path: str) -> str:
+ return (REPO_ROOT / path).read_text(encoding="utf-8")
+
+
+def same_file(left: str, right: str) -> bool:
+ return (REPO_ROOT / left).read_bytes() == (REPO_ROOT / right).read_bytes()
+
+
+def tracked_public_files() -> list[Path]:
+ result = subprocess.run(
+ ["git", "ls-files", *PUBLIC_SCAN_ROOTS],
+ cwd=REPO_ROOT,
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ files: list[Path] = []
+ for line in result.stdout.splitlines():
+ path = Path(line)
+ if path.suffix in PUBLIC_EXTENSIONS and (REPO_ROOT / path).exists():
+ files.append(path)
+ return files
+
+
+def main() -> int:
+ errors: list[str] = []
+
+ for path, actions in EXPECTED_ACTIONS.items():
+ text = read(path)
+ for action in actions:
+ if action not in text:
+ errors.append(f"{path} is missing {action}")
+
+ for path in (
+ "AGENTS.md",
+ ".github/CONTRIBUTING.md",
+ ".github/pull_request_template.md",
+ "docs/PUBLISHING_CHECKLIST.md",
+ "docs/VERSIONING.md",
+ "docs/prompts/bootstrap-planning-agent.md",
+ ".github/workflows/release-notes.yml",
+ ):
+ if PY_COMPILE_COMMAND not in read(path):
+ errors.append(
+ f"{path} must document the full py_compile command including bbackup/data/*.py"
+ )
+
+ if (REPO_ROOT / "setup.py").exists():
+ errors.append("setup.py should not be present; pyproject.toml is the packaging source")
+ for legacy_requirements in ("requirements.txt", "requirements-dev.txt"):
+ if (REPO_ROOT / legacy_requirements).exists():
+ errors.append(f"{legacy_requirements} should not be present; use pyproject.toml and uv.lock")
+ if not (REPO_ROOT / "uv.lock").exists():
+ errors.append("uv.lock is missing")
+ if (REPO_ROOT / ".python-version").read_text(encoding="utf-8").strip() != "3.12":
+ errors.append(".python-version must pin the local baseline to 3.12")
+ for source, packaged in (
+ ("config.yaml.example", "bbackup/data/config.yaml.example"),
+ ("docs/cli-skills.md", "bbackup/data/cli-skills.md"),
+ ("docs/cli-skills-index.json", "bbackup/data/cli-skills-index.json"),
+ ):
+ if not (REPO_ROOT / packaged).exists():
+ errors.append(f"{packaged} is missing from packaged runtime resources")
+ elif not same_file(source, packaged):
+ errors.append(f"{packaged} is not in sync with {source}")
+
+ release_workflow = read(".github/workflows/release-notes.yml")
+ if 'exit 1' not in release_workflow or "No CHANGELOG.md section found" not in release_workflow:
+ errors.append(".github/workflows/release-notes.yml must fail when release notes are missing")
+ if "contents: write" not in release_workflow:
+ errors.append(".github/workflows/release-notes.yml must grant contents: write")
+ if "uv build" not in release_workflow or "files: dist/*" not in release_workflow:
+ errors.append(".github/workflows/release-notes.yml must build and attach dist artifacts")
+ if 'test "${{ steps.version.outputs.version }}" = "$(cat VERSION)"' not in release_workflow:
+ errors.append(".github/workflows/release-notes.yml must verify the tag matches VERSION")
+ for release_check in (
+ "uv run ruff check",
+ "uv run python -m py_compile",
+ "uv run python scripts/generate_cli_skills.py --check",
+ "uv run pytest",
+ "uv run python scripts/smoke_installed_artifact.py",
+ ):
+ if release_check not in release_workflow:
+ errors.append(f".github/workflows/release-notes.yml must run {release_check}")
+
+ ci_workflow = read(".github/workflows/ci.yml")
+ if "contents: read" not in ci_workflow:
+ errors.append(".github/workflows/ci.yml must grant contents: read")
+ if "python-version: [\"3.12\", \"3.13\"]" not in ci_workflow:
+ errors.append(".github/workflows/ci.yml must test Python 3.12 and 3.13 only")
+ if "uv sync --locked" not in ci_workflow or "uv run pytest" not in ci_workflow:
+ errors.append(".github/workflows/ci.yml must install and test through uv")
+ if "python -m pip install uv" not in ci_workflow:
+ errors.append(".github/workflows/ci.yml must install uv without disallowed third-party actions")
+ for target in PY_COMPILE_TARGETS:
+ if target not in ci_workflow:
+ errors.append(f".github/workflows/ci.yml py_compile step is missing {target}")
+
+ stale_workflow = read(".github/workflows/stale.yml")
+ if "issues: write" not in stale_workflow or "pull-requests: write" not in stale_workflow:
+ errors.append(".github/workflows/stale.yml must grant issues and pull-requests write permissions")
+
+ public_files = tracked_public_files()
+ for path in public_files:
+ text = read(str(path))
+ if LEGACY_REPO in text:
+ errors.append(f"{path} still references {LEGACY_REPO}")
+
+ for path in ("README.md", "INSTALL.md", "QUICKSTART.md", "SUPPORT.md"):
+ if CANONICAL_REPO not in read(path):
+ errors.append(f"{path} does not reference {CANONICAL_REPO}")
+
+ for path in public_files:
+ text = read(str(path))
+ if "github.com/" in text and "best-backup" in text:
+ if CANONICAL_REPO not in text and path != Path("docs/prompts/bootstrap-planning-agent.md"):
+ errors.append(f"{path} has a best-backup GitHub URL but not {CANONICAL_REPO}")
+
+ changelog = read("CHANGELOG.md")
+ if not re.search(r"https://github\.com/CruxExperts/best-backup/compare/v\d+\.\d+\.\d+\.\.\.HEAD", changelog):
+ errors.append("CHANGELOG.md [Unreleased] compare link is not canonical")
+
+ if errors:
+ for error in errors:
+ print(f"publishing-ready: {error}", file=sys.stderr)
+ return 1
+
+ print("publishing-ready: ok")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/check_version_sync.py b/scripts/check_version_sync.py
new file mode 100644
index 0000000..108bd75
--- /dev/null
+++ b/scripts/check_version_sync.py
@@ -0,0 +1,86 @@
+"""
+Check that release-facing version references match VERSION.
+"""
+
+from __future__ import annotations
+
+import argparse
+import ast
+import re
+import sys
+import tomllib
+from pathlib import Path
+
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+VERSION_FILE = REPO_ROOT / "VERSION"
+
+
+def read_version() -> str:
+ version = VERSION_FILE.read_text(encoding="utf-8").strip()
+ if not re.fullmatch(r"\d+\.\d+\.\d+", version):
+ raise ValueError(f"VERSION must be semantic MAJOR.MINOR.PATCH, got {version!r}")
+ return version
+
+
+def read_python_assignment(path: Path, name: str) -> str | None:
+ tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
+ for node in ast.walk(tree):
+ if not isinstance(node, ast.Assign):
+ continue
+ if not any(isinstance(target, ast.Name) and target.id == name for target in node.targets):
+ continue
+ if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
+ return node.value.value
+ return None
+
+
+def read_pyproject() -> dict:
+ return tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
+
+
+def check_file_contains(path: Path, expected: str, errors: list[str]) -> None:
+ text = path.read_text(encoding="utf-8")
+ if expected not in text:
+ errors.append(f"{path.relative_to(REPO_ROOT)} does not contain {expected!r}")
+
+
+def main(argv: list[str]) -> int:
+ parser = argparse.ArgumentParser(description="Check version references against VERSION.")
+ parser.parse_args(argv)
+
+ errors: list[str] = []
+ version = read_version()
+
+ package_version = read_python_assignment(REPO_ROOT / "bbackup" / "__init__.py", "__version__")
+ if package_version != version:
+ errors.append(f"bbackup/__init__.py __version__ is {package_version!r}, expected {version!r}")
+
+ pyproject = read_pyproject()
+ project = pyproject.get("project", {})
+ pyproject_version = project.get("version")
+ if pyproject_version != version:
+ errors.append(f"pyproject.toml project.version is {pyproject_version!r}, expected {version!r}")
+
+ requires_python = project.get("requires-python")
+ if requires_python != ">=3.12":
+ errors.append(f"pyproject.toml project.requires-python is {requires_python!r}, expected '>=3.12'")
+
+ check_file_contains(REPO_ROOT / "README.md", f"version-{version}-", errors)
+ check_file_contains(REPO_ROOT / "README.md", "python-3.12%2B-", errors)
+ check_file_contains(REPO_ROOT / "README.md", f'"version": "{version}"', errors)
+ check_file_contains(REPO_ROOT / "CHANGELOG.md", f"## [{version}]", errors)
+ check_file_contains(REPO_ROOT / "CHANGELOG.md", f"v{version}...HEAD", errors)
+ check_file_contains(REPO_ROOT / "docs" / "cli-skills.md", f"Version: {version}.", errors)
+
+ if errors:
+ for error in errors:
+ print(f"version-sync: {error}", file=sys.stderr)
+ return 1
+
+ print(f"version-sync: ok ({version})")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/scripts/create_sandbox.py b/scripts/create_sandbox.py
index 7451f72..ccb6291 100755
--- a/scripts/create_sandbox.py
+++ b/scripts/create_sandbox.py
@@ -219,12 +219,13 @@ def main():
fmt.Println("Hello, World!")
}
''',
- "Dockerfile": '''FROM python:3.9-slim
+ "Dockerfile": '''FROM python:3.12-slim
WORKDIR /app
-COPY requirements.txt .
-RUN pip install -r requirements.txt
+COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
+COPY pyproject.toml uv.lock ./
+RUN uv sync --locked
COPY . .
-CMD ["python", "app.py"]
+CMD ["uv", "run", "python", "app.py"]
''',
}
diff --git a/scripts/generate_cli_skills.py b/scripts/generate_cli_skills.py
index 3e971f2..83d96ec 100644
--- a/scripts/generate_cli_skills.py
+++ b/scripts/generate_cli_skills.py
@@ -14,13 +14,17 @@
from pathlib import Path
from typing import Dict, List
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(REPO_ROOT))
+
from bbackup import __version__ as BBACKUP_VERSION
from bbackup.cli_metadata import CliCommand, get_command_registry
-
-REPO_ROOT = Path(__file__).resolve().parent.parent
DOC_PATH = REPO_ROOT / "docs" / "cli-skills.md"
INDEX_PATH = REPO_ROOT / "docs" / "cli-skills-index.json"
+PACKAGE_DOC_PATH = REPO_ROOT / "bbackup" / "data" / "cli-skills.md"
+PACKAGE_INDEX_PATH = REPO_ROOT / "bbackup" / "data" / "cli-skills-index.json"
def _render_header(lines: List[str]) -> None:
@@ -131,10 +135,14 @@ def generate_markdown_and_index() -> Dict[str, Dict[str, int]]:
meta["end"] = next_start - 1
index[cmd_id] = meta
- content = "\n".join(lines) + "\n"
+ content = "\n".join(lines).rstrip() + "\n"
+ index_content = json.dumps(index, indent=2, sort_keys=True) + "\n"
DOC_PATH.parent.mkdir(parents=True, exist_ok=True)
+ PACKAGE_DOC_PATH.parent.mkdir(parents=True, exist_ok=True)
DOC_PATH.write_text(content, encoding="utf-8")
- INDEX_PATH.write_text(json.dumps(index, indent=2, sort_keys=True), encoding="utf-8")
+ INDEX_PATH.write_text(index_content, encoding="utf-8")
+ PACKAGE_DOC_PATH.write_text(content, encoding="utf-8")
+ PACKAGE_INDEX_PATH.write_text(index_content, encoding="utf-8")
return index
@@ -142,11 +150,16 @@ def generate_markdown_and_index() -> Dict[str, Dict[str, int]]:
def check_up_to_date() -> bool:
if not DOC_PATH.exists() or not INDEX_PATH.exists():
return False
+ if not PACKAGE_DOC_PATH.exists() or not PACKAGE_INDEX_PATH.exists():
+ return False
# Generate into memory and compare (normalize newlines and trailing blanks)
text_before = DOC_PATH.read_text(encoding="utf-8").replace("\r\n", "\n").rstrip("\n")
+ package_text_before = PACKAGE_DOC_PATH.read_text(encoding="utf-8").replace("\r\n", "\n").rstrip("\n")
with open(INDEX_PATH, "r", encoding="utf-8") as f:
index_before = json.load(f)
+ with open(PACKAGE_INDEX_PATH, "r", encoding="utf-8") as f:
+ package_index_before = json.load(f)
# Temporarily generate to a temp structure
lines: List[str] = []
@@ -174,8 +187,12 @@ def check_up_to_date() -> bool:
text_after = "\n".join(lines).replace("\r\n", "\n").rstrip("\n")
if text_after != text_before:
return False
+ if text_after != package_text_before:
+ return False
if index != index_before:
return False
+ if index != package_index_before:
+ return False
return True
@@ -203,4 +220,3 @@ def main(argv: List[str]) -> int:
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
-
diff --git a/scripts/run_tests.py b/scripts/run_tests.py
index 51e130e..f3bea37 100644
--- a/scripts/run_tests.py
+++ b/scripts/run_tests.py
@@ -2,11 +2,11 @@
Agentic sandbox test runner for bbackup.
Builds a Docker test image, runs pytest inside it, streams output live,
and on failure applies targeted auto-fixes then retries. Writes a final
-report to docs/tests/ci-test-report.md regardless of outcome.
+local report to .localsetup/reports/ci-test-report.md regardless of outcome.
Usage:
- python scripts/run_tests.py [--unit] [--integration] [--all]
- [--no-sandbox] [--max-retries N]
+ uv run python scripts/run_tests.py [--unit] [--integration] [--all]
+ [--no-sandbox] [--max-retries N]
Default: --unit (excludes integration tests).
--no-sandbox: runs pytest directly on host without Docker (useful for CI).
@@ -24,8 +24,8 @@
from pathlib import Path
REPO_ROOT = Path(__file__).parent.parent.resolve()
-DOCS_TESTS_DIR = REPO_ROOT / "docs" / "tests"
-REPORT_FILE = DOCS_TESTS_DIR / "ci-test-report.md"
+REPORT_DIR = REPO_ROOT / ".localsetup" / "reports"
+REPORT_FILE = REPORT_DIR / "ci-test-report.md"
IMAGE_TAG = "bbackup:test"
MAX_RETRIES_DEFAULT = 3
@@ -257,8 +257,8 @@ def write_report(
attempt_count: int,
coverage_data: dict,
):
- """Write ci-test-report.md to docs/tests/."""
- DOCS_TESTS_DIR.mkdir(parents=True, exist_ok=True)
+ """Write ci-test-report.md to ignored local report state."""
+ REPORT_DIR.mkdir(parents=True, exist_ok=True)
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
python_version = sys.version.split()[0]
@@ -337,7 +337,7 @@ def main():
if not args.no_sandbox:
build_image()
- output_dir = REPO_ROOT / "docs" / "tests" / "output"
+ output_dir = REPORT_DIR / "output"
all_fixes = []
attempt = 0
exit_code = 1
diff --git a/scripts/smoke_installed_artifact.py b/scripts/smoke_installed_artifact.py
new file mode 100644
index 0000000..1b06934
--- /dev/null
+++ b/scripts/smoke_installed_artifact.py
@@ -0,0 +1,95 @@
+"""
+Smoke test a built wheel in an isolated environment.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import subprocess
+import sys
+import tempfile
+from pathlib import Path
+
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+
+
+def run(command: list[str], *, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
+ return subprocess.run(command, cwd=REPO_ROOT, env=env, capture_output=True, text=True, timeout=60)
+
+
+def assert_ok(command: list[str], *, env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
+ result = run(command, env=env)
+ if result.returncode != 0:
+ sys.stderr.write(f"smoke: command failed: {' '.join(command)}\n")
+ sys.stderr.write(result.stdout)
+ sys.stderr.write(result.stderr)
+ raise SystemExit(result.returncode)
+ return result
+
+
+def latest_wheel(dist_dir: Path) -> Path:
+ wheels = sorted(dist_dir.glob("bbackup-*.whl"))
+ if not wheels:
+ raise SystemExit("smoke: no bbackup wheel found in dist/")
+ return wheels[-1]
+
+
+def main(argv: list[str]) -> int:
+ parser = argparse.ArgumentParser(description="Smoke test a built bbackup wheel.")
+ parser.add_argument("--wheel", type=Path, default=None, help="Wheel path. Defaults to latest dist/bbackup-*.whl.")
+ args = parser.parse_args(argv)
+
+ wheel = args.wheel or latest_wheel(REPO_ROOT / "dist")
+ if not wheel.exists():
+ raise SystemExit(f"smoke: wheel not found: {wheel}")
+
+ with tempfile.TemporaryDirectory(prefix="bbackup-wheel-smoke-") as tmp:
+ smoke_root = Path(tmp)
+ venv = smoke_root / ".venv"
+ env = {
+ "HOME": str(smoke_root),
+ }
+
+ assert_ok(["uv", "venv", str(venv), "--python", "3.12"])
+ assert_ok(["uv", "pip", "install", "--python", str(venv / "bin" / "python"), str(wheel)])
+
+ bin_dir = venv / "bin"
+ bbackup = str(bin_dir / "bbackup")
+ bbman = str(bin_dir / "bbman")
+
+ assert_ok([bbackup, "--version"], env=env)
+ assert_ok([bbman, "--version"], env=env)
+
+ init_result = assert_ok([bbackup, "init-config", "--output", "json"], env=env)
+ init_payload = json.loads(init_result.stdout)
+ if not init_payload.get("success"):
+ raise SystemExit("smoke: init-config did not report success")
+
+ config_path = smoke_root / ".config" / "bbackup" / "config.yaml"
+ if not config_path.exists():
+ raise SystemExit(f"smoke: init-config did not create {config_path}")
+
+ skills_result = assert_ok([bbackup, "skills", "--format", "markdown"], env=env)
+ if "# CLI skills catalog" not in skills_result.stdout:
+ raise SystemExit("smoke: bbackup skills markdown missing catalog header")
+
+ bbman_skills_result = assert_ok([bbman, "skills", "--format", "markdown"], env=env)
+ if "# CLI skills catalog" not in bbman_skills_result.stdout:
+ raise SystemExit("smoke: bbman skills markdown missing catalog header")
+
+ command_skills_result = assert_ok([bbackup, "init-config", "--skills"], env=env)
+ if "### bbackup init-config" not in command_skills_result.stdout:
+ raise SystemExit("smoke: bbackup init-config --skills missing command section")
+
+ repo_url_result = assert_ok([bbman, "repo-url", "--output", "json"], env=env)
+ if "https://github.com/CruxExperts/best-backup" not in repo_url_result.stdout:
+ raise SystemExit("smoke: bbman repo-url did not report canonical repository")
+
+ print(f"smoke: ok ({wheel})")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 82babb1..0000000
--- a/setup.py
+++ /dev/null
@@ -1,63 +0,0 @@
-"""
-Setup script for bbackup package.
-"""
-
-from setuptools import setup, find_packages
-from pathlib import Path
-
-# Read README
-readme_file = Path(__file__).parent / "README.md"
-long_description = readme_file.read_text() if readme_file.exists() else ""
-
-setup(
- name="bbackup",
- version="1.7.0",
- description="Docker Backup Tool with Rich TUI",
- long_description=long_description,
- long_description_content_type="text/markdown",
- author="Slavic Kozyuk / Crux Experts LLC",
- url="https://github.com/cptnfren/best-backup",
- packages=find_packages(),
- keywords=[
- "docker",
- "backup",
- "containers",
- "volumes",
- "tui",
- "rsync",
- "encryption",
- "restore",
- ],
- install_requires=[
- "rich>=13.7.0",
- "pyyaml>=6.0.1",
- "docker>=7.0.0",
- "click>=8.1.7",
- "paramiko>=3.4.0",
- "cryptography>=41.0.0",
- "requests>=2.31.0",
- ],
- extras_require={
- "management": [
- "gitpython>=3.1.0", # Optional, for Git-based updates
- ],
- },
- python_requires=">=3.10",
- entry_points={
- "console_scripts": [
- "bbackup=bbackup.cli:cli",
- "bbman=bbackup.bbman_entry:cli",
- ],
- },
- classifiers=[
- "Development Status :: 4 - Beta",
- "Intended Audience :: System Administrators",
- "License :: OSI Approved :: MIT License",
- "Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.10",
- "Programming Language :: Python :: 3.11",
- "Programming Language :: Python :: 3.12",
- "Programming Language :: Python :: 3.13",
- "Topic :: System :: Archiving :: Backup",
- ],
-)
diff --git a/tests/test_archive.py b/tests/test_archive.py
index c4487db..685466a 100644
--- a/tests/test_archive.py
+++ b/tests/test_archive.py
@@ -74,6 +74,23 @@ def test_create_solid_archive_removes_partial_on_failure(self, tmp_path):
partial = backup_dir.parent / "backup_20260304_120000.tar.gz"
assert not partial.exists()
+ def test_create_solid_archive_preserves_existing_final_on_failure(self, tmp_path):
+ backup_dir = tmp_path / "backup_20260304_120000"
+ backup_dir.mkdir()
+ final = backup_dir.parent / "backup_20260304_120000.tar.gz"
+ final.write_bytes(b"existing")
+ compression = {"enabled": True, "level": 6, "format": "gzip"}
+ enc = MagicMock()
+ enc.enabled = True
+
+ with patch("bbackup.archive.EncryptionManager") as MockEnc:
+ MockEnc.return_value.encrypt_file.return_value = False
+ with pytest.raises(OSError):
+ create_solid_archive(backup_dir, compression, encryption_config=enc)
+
+ assert final.read_bytes() == b"existing"
+ assert not final.with_name(f"{final.name}.partial").exists()
+
class TestUnpackSolidArchive:
def test_unpack_returns_dir_unchanged(self, tmp_path):
diff --git a/tests/test_backup_runner.py b/tests/test_backup_runner.py
index 4370aec..fdba7bf 100644
--- a/tests/test_backup_runner.py
+++ b/tests/test_backup_runner.py
@@ -5,6 +5,7 @@
Last Updated: 2026-02-26
"""
+import json
import threading
from unittest.mock import MagicMock, patch
@@ -72,6 +73,36 @@ def test_basic_run_sets_completed_status(self, mock_docker_client, tmp_path):
runner.run_backup(tmp_path, scope=scope)
assert runner.status.status == "completed"
+ def test_item_failure_sets_partial_status(self, mock_docker_client, tmp_path):
+ runner = make_runner(mock_docker_client, tmp_path)
+ runner._mock_db.get_all_volumes.return_value = [{"name": "mydata"}]
+ runner._mock_db.backup_volume.return_value = False
+ scope = BackupScope(containers=False, volumes=True, networks=False, configs=False)
+
+ result = runner.run_backup(tmp_path, scope=scope)
+
+ assert result["volumes"]["mydata"] == "failed"
+ assert result["errors"] == ["Failed to backup volume: mydata"]
+ assert runner.status.status == "partial"
+ manifest = json.loads((tmp_path / "backup_manifest.json").read_text())
+ assert manifest["status"] == "partial"
+ assert manifest["item_results"]["volumes"]["mydata"] == "failed"
+ assert manifest["errors"] == ["Failed to backup volume: mydata"]
+
+ def test_run_backup_writes_manifest(self, mock_docker_client, tmp_path):
+ runner = make_runner(mock_docker_client, tmp_path)
+ scope = BackupScope(containers=False, volumes=False, networks=False, configs=False)
+
+ runner.run_backup(tmp_path, scope=scope)
+
+ manifest_file = tmp_path / "backup_manifest.json"
+ assert manifest_file.exists()
+ manifest = json.loads(manifest_file.read_text())
+ assert manifest["schema_version"] == 1
+ assert manifest["backup_name"] == tmp_path.name
+ assert manifest["status"] == "complete"
+ assert manifest["source_scope"]["volumes"] is False
+
def test_start_called_before_work(self, mock_docker_client, tmp_path):
runner = make_runner(mock_docker_client, tmp_path)
scope = BackupScope(containers=False, volumes=False, networks=False, configs=False)
@@ -364,16 +395,37 @@ def test_encryption_enabled_calls_manager(self, mock_docker_client, tmp_path):
assert result == encrypted
assert runner.status.encryption_status == "encrypted"
- def test_encryption_exception_returns_original(self, mock_docker_client, tmp_path):
+ def test_encryption_exception_raises(self, mock_docker_client, tmp_path):
runner = make_runner(mock_docker_client, tmp_path)
runner.config.encryption.enabled = True
with patch("bbackup.backup_runner.EncryptionManager", side_effect=RuntimeError("key error")):
- result = runner.encrypt_backup_directory(tmp_path)
+ try:
+ runner.encrypt_backup_directory(tmp_path)
+ except RuntimeError as exc:
+ assert "Encryption failed" in str(exc)
+ else:
+ raise AssertionError("encryption failure did not raise")
- assert result == tmp_path
assert len(runner.status.errors) >= 1
+ def test_encryption_same_path_raises(self, mock_docker_client, tmp_path):
+ runner = make_runner(mock_docker_client, tmp_path)
+ runner.config.encryption.enabled = True
+
+ with patch("bbackup.backup_runner.EncryptionManager") as MockEM:
+ instance = MagicMock()
+ instance.encrypt_backup.return_value = tmp_path
+ MockEM.return_value = instance
+ try:
+ runner.encrypt_backup_directory(tmp_path)
+ except RuntimeError as exc:
+ assert "Encryption failed" in str(exc)
+ else:
+ raise AssertionError("same-path encryption failure did not raise")
+
+ assert runner.status.encryption_status == "failed"
+
# ---------------------------------------------------------------------------
# TestUploadToRemotes
diff --git a/tests/test_cli.py b/tests/test_cli.py
index c5ccbdc..0d1a627 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -197,6 +197,29 @@ def test_asymmetric_generates_keypair(self, tmp_path):
)
assert result.exception is None or result.exit_code in (0, 1)
+ def test_init_encryption_rejects_password(self, tmp_path):
+ key_dir = tmp_path / "keys"
+ result = CliRunner().invoke(
+ cli,
+ [
+ "init-encryption",
+ "--key-path",
+ str(key_dir),
+ "--password",
+ "secret",
+ "--output",
+ "json",
+ ],
+ )
+ assert result.exit_code == EXIT_USER_ERROR
+ data = json.loads(result.output)
+ assert data["success"] is False
+ assert not key_dir.exists()
+
+ def test_init_encryption_rejects_ecdsa_option(self):
+ result = CliRunner().invoke(cli, ["init-encryption", "--algorithm", "ecdsa-p384"])
+ assert result.exit_code != EXIT_SUCCESS
+
# ---------------------------------------------------------------------------
# TestListRemoteBackupsCommand
@@ -386,6 +409,182 @@ def test_backup_invalid_backup_set_exits_one(self):
result = CliRunner().invoke(cli, ["backup", "--backup-set", "nonexistent_set"])
assert result.exit_code == 1
+ def test_backup_duplicate_direct_path_names_exit_user_error(self, tmp_path):
+ src_a = tmp_path / "a" / "data"
+ src_b = tmp_path / "b" / "data"
+ src_a.mkdir(parents=True)
+ src_b.mkdir(parents=True)
+
+ result = CliRunner().invoke(
+ cli,
+ [
+ "backup",
+ "--containers",
+ "myapp",
+ "--paths",
+ str(src_a),
+ "--paths",
+ str(src_b),
+ "--output",
+ "json",
+ ],
+ )
+
+ assert result.exit_code == EXIT_USER_ERROR
+ data = json.loads(result.output)
+ assert "Duplicate filesystem target name" in data["errors"][0]
+
+ def test_backup_partial_failure_exits_partial_and_skips_upload(self, tmp_path):
+ cfg_file = tmp_path / "config.yaml"
+ remote_dir = tmp_path / "remote"
+ cfg_file.write_text(textwrap.dedent(f"""
+ backup:
+ staging_dir: {tmp_path / "staging"}
+ solid_archive: false
+ remotes:
+ local1:
+ enabled: true
+ type: local
+ path: {remote_dir}
+ encryption:
+ enabled: false
+ """))
+ runner_ref = {}
+
+ def make_runner(config, status):
+ mock_runner = MagicMock()
+ mock_runner.run_backup.side_effect = lambda **kwargs: (
+ status.add_error("Failed to backup volume: data")
+ or setattr(status, "status", "partial")
+ or {"errors": ["Failed to backup volume: data"]}
+ )
+ runner_ref["runner"] = mock_runner
+ return mock_runner
+
+ with patch("bbackup.cli.BackupRunner", side_effect=make_runner):
+ result = CliRunner().invoke(
+ cli,
+ [
+ "--config",
+ str(cfg_file),
+ "backup",
+ "--containers",
+ "myapp",
+ "--output",
+ "json",
+ ],
+ )
+
+ assert result.exit_code == EXIT_PARTIAL
+ data = json.loads(result.output)
+ assert data["success"] is False
+ assert data["data"]["remotes"] == {}
+ assert data["data"]["encryption"] == "disabled"
+ runner_ref["runner"].upload_to_remotes.assert_not_called()
+
+ def test_backup_successful_encryption_removes_plaintext_staging(self, tmp_path):
+ cfg_file = tmp_path / "config.yaml"
+ cfg_file.write_text(textwrap.dedent(f"""
+ backup:
+ staging_dir: {tmp_path / "staging"}
+ solid_archive: false
+ remotes: {{}}
+ encryption:
+ enabled: true
+ """))
+ paths = {}
+
+ def make_runner(config, status):
+ mock_runner = MagicMock()
+
+ def run_backup(**kwargs):
+ backup_dir = kwargs["backup_dir"]
+ backup_dir.mkdir(parents=True, exist_ok=True)
+ (backup_dir / "plain.txt").write_text("secret")
+ paths["plain"] = backup_dir
+ return {}
+
+ def encrypt_backup(backup_dir):
+ encrypted_dir = backup_dir.parent / f"{backup_dir.name}.enc"
+ encrypted_dir.mkdir(parents=True)
+ (encrypted_dir / "plain.txt.enc").write_text("encrypted")
+ status.encryption_status = "encrypted"
+ return encrypted_dir
+
+ mock_runner.run_backup.side_effect = run_backup
+ mock_runner.encrypt_backup_directory.side_effect = encrypt_backup
+ return mock_runner
+
+ with patch("bbackup.cli.BackupRunner", side_effect=make_runner):
+ result = CliRunner().invoke(
+ cli,
+ [
+ "--config",
+ str(cfg_file),
+ "backup",
+ "--containers",
+ "myapp",
+ "--output",
+ "json",
+ ],
+ )
+
+ assert result.exit_code == EXIT_SUCCESS
+ data = json.loads(result.output)
+ assert data["data"]["encryption"] == "encrypted"
+ assert data["data"]["backup_dir"].endswith(".enc")
+ assert not paths["plain"].exists()
+
+ def test_backup_encryption_failure_exits_system_error_and_skips_upload(self, tmp_path):
+ cfg_file = tmp_path / "config.yaml"
+ remote_dir = tmp_path / "remote"
+ cfg_file.write_text(textwrap.dedent(f"""
+ backup:
+ staging_dir: {tmp_path / "staging"}
+ solid_archive: false
+ remotes:
+ local1:
+ enabled: true
+ type: local
+ path: {remote_dir}
+ encryption:
+ enabled: true
+ """))
+ runner_ref = {}
+
+ def make_runner(config, status):
+ mock_runner = MagicMock()
+ mock_runner.run_backup.return_value = {}
+
+ def fail_encryption(path):
+ status.add_error("Encryption failed: key error")
+ status.status = "error"
+ raise RuntimeError("Encryption failed: key error")
+
+ mock_runner.encrypt_backup_directory.side_effect = fail_encryption
+ runner_ref["runner"] = mock_runner
+ return mock_runner
+
+ with patch("bbackup.cli.BackupRunner", side_effect=make_runner):
+ result = CliRunner().invoke(
+ cli,
+ [
+ "--config",
+ str(cfg_file),
+ "backup",
+ "--containers",
+ "myapp",
+ "--output",
+ "json",
+ ],
+ )
+
+ assert result.exit_code == EXIT_SYSTEM_ERROR
+ data = json.loads(result.output)
+ assert data["success"] is False
+ assert data["data"]["remotes"] == {}
+ runner_ref["runner"].upload_to_remotes.assert_not_called()
+
# ---------------------------------------------------------------------------
# TestJSONOutputMode
@@ -695,6 +894,20 @@ def test_dry_run_output_shape(self, mock_docker_client):
assert "would_backup" in data["data"]
assert "containers" in data["data"]["would_backup"]
+ def test_restore_all_dry_run_includes_archive_backed_volumes(self, tmp_path):
+ volumes_dir = tmp_path / "volumes"
+ volumes_dir.mkdir()
+ (volumes_dir / "myvolume.tar.gz").write_bytes(b"")
+
+ result = CliRunner().invoke(
+ cli,
+ ["restore", "--backup-path", str(tmp_path), "--all", "--dry-run", "--output", "json"],
+ )
+
+ assert result.exit_code == EXIT_SUCCESS
+ data = json.loads(result.output)
+ assert data["data"]["would_restore"]["volumes"] == ["myvolume"]
+
# ---------------------------------------------------------------------------
# TestEnvVars
diff --git a/tests/test_config.py b/tests/test_config.py
index db5604a..52d0dd2 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -270,6 +270,21 @@ def test_solid_archive_default_false(self):
cfg = Config(config_path=None)
assert cfg.solid_archive is False
+ def test_duplicate_filesystem_target_names_raise(self, tmp_path):
+ cfg_file = tmp_path / "config.yaml"
+ cfg_file.write_text(textwrap.dedent("""
+ filesystem:
+ local:
+ targets:
+ - name: data
+ path: /srv/data
+ - name: data
+ path: /opt/data
+ """))
+
+ with pytest.raises(ValueError, match="Duplicate filesystem target name"):
+ Config(config_path=str(cfg_file))
+
def test_get_backup_compression_returns_dict_with_defaults(self):
cfg = Config(config_path=None)
comp = cfg.get_backup_compression()
diff --git a/tests/test_docker_backup.py b/tests/test_docker_backup.py
index ef19be3..6b9fe44 100644
--- a/tests/test_docker_backup.py
+++ b/tests/test_docker_backup.py
@@ -256,6 +256,25 @@ def test_rsync_success(self, mock_docker_client, mock_subprocess, tmp_path):
result = db.backup_volume("myvolume", tmp_path)
assert result is True
+ def test_rsync_docker_cp_failure_returns_false(self, mock_docker_client, mock_subprocess, tmp_path):
+ mock_run, _ = mock_subprocess
+ mock_docker_client.volumes.get.return_value = MagicMock()
+ temp_container = MagicMock()
+ mock_docker_client.containers.run.return_value = temp_container
+
+ def run_side_effect(cmd, **kwargs):
+ result = MagicMock(returncode=0, stdout="", stderr="")
+ if cmd[:2] == ["docker", "cp"]:
+ result.returncode = 1
+ result.stderr = "copy failed"
+ return result
+
+ mock_run.side_effect = run_side_effect
+
+ db = make_backup(mock_docker_client)
+ assert db.backup_volume("myvolume", tmp_path) is False
+ assert not (tmp_path / "volumes" / "myvolume").exists()
+
def test_volume_not_found_returns_false(self, mock_docker_client, tmp_path):
from docker.errors import APIError
mock_docker_client.volumes.get.side_effect = APIError("not found")
diff --git a/tests/test_encryption.py b/tests/test_encryption.py
index 28cf6c9..5fe8d9c 100644
--- a/tests/test_encryption.py
+++ b/tests/test_encryption.py
@@ -478,3 +478,7 @@ def test_generate_keypair_pem_loadable(self):
def test_generate_keypair_unsupported_raises(self):
with pytest.raises(ValueError, match="Unsupported"):
EncryptionManager.generate_keypair(algorithm="unsupported")
+
+ def test_generate_keypair_ecdsa_unsupported(self):
+ with pytest.raises(ValueError, match="Unsupported"):
+ EncryptionManager.generate_keypair(algorithm="ecdsa-p384")
diff --git a/tests/test_management.py b/tests/test_management.py
index 5bb0173..60ded68 100644
--- a/tests/test_management.py
+++ b/tests/test_management.py
@@ -402,10 +402,11 @@ def test_check_python_dependencies_all_present(self):
assert isinstance(installed, list)
assert isinstance(missing, list)
- def test_check_requirements_file_reads_packages(self):
- from bbackup.management.dependencies import check_requirements_file
- result = check_requirements_file()
+ def test_check_project_dependencies_reads_packages(self):
+ from bbackup.management.dependencies import check_project_dependencies
+ result = check_project_dependencies()
assert isinstance(result, list)
+ assert "rich" in result
def test_install_python_packages_success(self):
from bbackup.management.dependencies import install_python_packages
@@ -436,7 +437,7 @@ def test_check_and_install_with_missing_install_confirm(self):
return_value=(False, ["rich", "click"], ["paramiko"])), \
patch("bbackup.management.dependencies.check_system_dependencies",
return_value={"docker": (True, "ok")}), \
- patch("bbackup.management.dependencies.check_requirements_file", return_value=[]), \
+ patch("bbackup.management.dependencies.check_project_dependencies", return_value=[]), \
patch("bbackup.management.dependencies.install_python_packages", return_value=True), \
patch("rich.prompt.Confirm.ask", return_value=True):
result = check_and_install_dependencies(install_missing=True)
@@ -596,7 +597,7 @@ def test_backup_repository_success(self, tmp_path):
src_dir.mkdir()
(src_dir / "bbackup").mkdir()
(src_dir / "bbackup" / "cli.py").write_text("# cli")
- (src_dir / "setup.py").write_text("# setup")
+ (src_dir / "pyproject.toml").write_text("[project]\nname = 'test'\n")
backup = tmp_path / "backup"
result = backup_repository(src_dir, backup)
diff --git a/tests/test_remote.py b/tests/test_remote.py
index 53fba81..3bbf598 100644
--- a/tests/test_remote.py
+++ b/tests/test_remote.py
@@ -4,6 +4,7 @@
Last Updated: 2026-02-26
"""
+import shutil
import textwrap
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -85,6 +86,39 @@ def test_upload_overwrites_existing_dir(self, tmp_path):
result = mgr.upload_to_local(remote, src_dir, str(dest))
assert result is True
+ def test_upload_file_uses_partial_then_replace(self, tmp_path):
+ mgr = make_manager()
+ src = tmp_path / "source.tar.gz"
+ src.write_bytes(b"new")
+ dest = tmp_path / "backup.tar.gz"
+ dest.write_bytes(b"old")
+ remote = make_remote(type_="local", path=str(tmp_path))
+
+ with patch("bbackup.remote.shutil.copy2", wraps=shutil.copy2) as mock_copy:
+ result = mgr.upload_to_local(remote, src, str(dest))
+
+ assert result is True
+ assert dest.read_bytes() == b"new"
+ assert not (tmp_path / "backup.tar.gz.partial").exists()
+ assert mock_copy.call_args.args[1] == dest.with_name("backup.tar.gz.partial")
+
+ def test_upload_directory_preserves_existing_until_copy_succeeds(self, tmp_path):
+ mgr = make_manager()
+ src_dir = tmp_path / "source"
+ src_dir.mkdir()
+ (src_dir / "new.txt").write_text("new")
+ dest = tmp_path / "dest"
+ dest.mkdir()
+ (dest / "old.txt").write_text("old")
+ remote = make_remote(type_="local", path=str(tmp_path))
+
+ with patch("bbackup.remote.shutil.copytree", side_effect=RuntimeError("copy failed")):
+ result = mgr.upload_to_local(remote, src_dir, str(dest))
+
+ assert result is False
+ assert (dest / "old.txt").exists()
+ assert not dest.with_name("dest.partial").exists()
+
# ---------------------------------------------------------------------------
# TestLocalListing
@@ -146,7 +180,7 @@ def test_list_backups_dispatches_rclone(self):
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(
returncode=0,
- stdout=" 100 backup_001\n 200 backup_002\n",
+ stdout="backup_001/\nbackup_002/\n",
stderr=""
)
result = mgr.list_backups(remote)
@@ -174,12 +208,14 @@ def test_upload_backup_dispatches_rclone(self, tmp_path):
src.mkdir()
remote = make_remote(type_="rclone", remote_name="myremote", path="backups")
with patch("shutil.which", return_value="/usr/bin/rclone"), \
- patch("subprocess.Popen") as mock_popen:
+ patch("subprocess.Popen") as mock_popen, \
+ patch("subprocess.run") as mock_run:
proc = MagicMock()
proc.stdout.__iter__ = MagicMock(return_value=iter([]))
proc.wait.return_value = None
proc.returncode = 0
mock_popen.return_value = proc
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
result = mgr.upload_backup(remote, src, "backup_20240101")
assert result is True
@@ -213,12 +249,14 @@ def test_happy_path_popen_returncode_zero(self, tmp_path):
mgr = make_manager()
remote = make_remote(type_="rclone", remote_name="myremote")
with patch("shutil.which", return_value="/usr/bin/rclone"), \
- patch("subprocess.Popen") as mock_popen:
+ patch("subprocess.Popen") as mock_popen, \
+ patch("subprocess.run") as mock_run:
proc = MagicMock()
proc.stdout.__iter__ = MagicMock(return_value=iter([]))
proc.wait.return_value = None
proc.returncode = 0
mock_popen.return_value = proc
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
result = mgr.upload_to_rclone(remote, tmp_path, "backups/bkp")
assert result is True
@@ -231,7 +269,8 @@ def callback(line):
received_lines.append(line)
with patch("shutil.which", return_value="/usr/bin/rclone"), \
- patch("subprocess.Popen") as mock_popen:
+ patch("subprocess.Popen") as mock_popen, \
+ patch("subprocess.run") as mock_run:
proc = MagicMock()
proc.stdout.__iter__ = MagicMock(
return_value=iter(["Transferred: 1.234 GiB", ""])
@@ -239,6 +278,7 @@ def callback(line):
proc.wait.return_value = None
proc.returncode = 0
mock_popen.return_value = proc
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
mgr.upload_to_rclone(remote, tmp_path, "backups/bkp", progress_callback=callback)
assert len(received_lines) >= 1
@@ -246,12 +286,14 @@ def callback(line):
def test_list_rclone_backups_parses_output(self):
mgr = make_manager()
remote = make_remote(type_="rclone", remote_name="myremote", path="backups")
- output = " 1234 backup_20240101_000000\n 5678 backup_20240201_000000\n"
+ output = "backup_20240101_000000/\nbackup_20240201_000000.tar.gz\nnested/file.txt\n"
with patch("subprocess.run") as mock_run:
mock_run.return_value = MagicMock(returncode=0, stdout=output, stderr="")
result = mgr._list_rclone_backups(remote)
assert "backup_20240101_000000" in result
- assert "backup_20240201_000000" in result
+ assert "backup_20240201_000000.tar.gz" in result
+ assert "nested/file.txt" not in result
+ assert mock_run.call_args.args[0][0:2] == ["rclone", "lsf"]
def test_list_rclone_backups_no_remote_name_returns_empty(self):
mgr = make_manager()
@@ -284,13 +326,62 @@ def test_upload_to_rclone_includes_transfers_and_checkers_from_config(self, tmp_
proc.wait.return_value = None
proc.returncode = 0
mock_popen.return_value = proc
- mgr.upload_to_rclone(remote, src, "backups/bkp")
+ with patch("subprocess.run") as mock_run:
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
+ mgr.upload_to_rclone(remote, src, "backups/bkp")
call_cmd = mock_popen.call_args[0][0]
assert "--transfers" in call_cmd
assert "12" in call_cmd
assert "--checkers" in call_cmd
assert "6" in call_cmd
+ def test_upload_to_rclone_uses_partial_then_moveto(self, tmp_path):
+ mgr = make_manager()
+ remote = make_remote(type_="rclone", remote_name="myremote")
+ src = tmp_path / "backup"
+ src.mkdir()
+
+ with patch("shutil.which", return_value="/usr/bin/rclone"), \
+ patch("subprocess.Popen") as mock_popen, \
+ patch("subprocess.run") as mock_run:
+ proc = MagicMock()
+ proc.stdout.__iter__ = MagicMock(return_value=iter([]))
+ proc.wait.return_value = None
+ proc.returncode = 0
+ mock_popen.return_value = proc
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
+
+ result = mgr.upload_to_rclone(remote, src, "backups/bkp")
+
+ assert result is True
+ assert mock_popen.call_args.args[0][3] == "myremote:backups/bkp.partial"
+ assert mock_run.call_args_list[0].args[0][0:2] == ["rclone", "moveto"]
+ assert mock_run.call_args_list[0].args[0][2:4] == [
+ "myremote:backups/bkp.partial",
+ "myremote:backups/bkp",
+ ]
+
+ def test_upload_to_rclone_removes_partial_on_copy_failure(self, tmp_path):
+ mgr = make_manager()
+ remote = make_remote(type_="rclone", remote_name="myremote")
+ src = tmp_path / "backup"
+ src.mkdir()
+
+ with patch("shutil.which", return_value="/usr/bin/rclone"), \
+ patch("subprocess.Popen") as mock_popen, \
+ patch("subprocess.run") as mock_run:
+ proc = MagicMock()
+ proc.stdout.__iter__ = MagicMock(return_value=iter([]))
+ proc.wait.return_value = None
+ proc.returncode = 1
+ mock_popen.return_value = proc
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
+
+ result = mgr.upload_to_rclone(remote, src, "backups/bkp")
+
+ assert result is False
+ assert mock_run.call_args.args[0][0:2] == ["rclone", "purge"]
+
def test_list_rclone_backups_includes_transfers_and_checkers_from_config(self, tmp_path):
cfg_file = tmp_path / "config.yaml"
cfg_file.write_text(textwrap.dedent("""
@@ -346,6 +437,8 @@ def test_file_upload_calls_sftp_put(self, tmp_path):
assert result is True
mock_sftp.put.assert_called_once()
+ assert mock_sftp.put.call_args.args[1] == "/remote/backups.partial"
+ mock_sftp.posix_rename.assert_called_once_with("/remote/backups.partial", "/remote/backups")
def test_no_key_file_passes_pkey_none(self, tmp_path):
mgr = make_manager()
@@ -401,7 +494,8 @@ def test_directory_upload_calls_upload_directory(self, tmp_path):
patch.object(mgr, "_upload_directory_sftp") as mock_upload_dir:
mgr.upload_to_sftp(remote, src_dir, "/remote/backups")
- mock_upload_dir.assert_called_once()
+ mock_upload_dir.assert_called_once_with(mock_sftp, src_dir, "/remote/backups.partial")
+ mock_sftp.posix_rename.assert_called_once_with("/remote/backups.partial", "/remote/backups")
def test_upload_directory_sftp_recursive(self, tmp_path):
"""_upload_directory_sftp calls put for files and mkdir+recurse for dirs."""
diff --git a/tests/test_restore.py b/tests/test_restore.py
index 6c73d19..dd591bc 100644
--- a/tests/test_restore.py
+++ b/tests/test_restore.py
@@ -7,6 +7,7 @@
"""
import json
+import tarfile
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -15,7 +16,8 @@
from docker.errors import DockerException, APIError
from bbackup.config import Config
-from bbackup.restore import DockerRestore
+from bbackup.manifest import generate_backup_manifest
+from bbackup.restore import DockerRestore, list_volume_backup_names
def make_restore(mock_docker_client):
@@ -265,6 +267,87 @@ def test_docker_cp_called(self, mock_docker_client, mock_subprocess, tmp_path):
]
assert len(cp_calls) >= 1
+ def test_compressed_volume_archive_restores(self, mock_docker_client, mock_subprocess, tmp_path):
+ mock_run, _ = mock_subprocess
+ source_dir = tmp_path / "source" / "myvolume"
+ source_dir.mkdir(parents=True)
+ (source_dir / "data.txt").write_text("payload")
+ volumes_dir = tmp_path / "volumes"
+ volumes_dir.mkdir()
+ with tarfile.open(volumes_dir / "myvolume.tar.gz", "w:gz") as tar:
+ tar.add(source_dir, arcname="myvolume")
+
+ mock_docker_client.volumes.get.side_effect = APIError("not found")
+ mock_docker_client.volumes.create.return_value = MagicMock()
+ temp_container = MagicMock()
+ mock_docker_client.containers.run.return_value = temp_container
+ mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
+
+ dr = make_restore(mock_docker_client)
+ assert dr.restore_volume("myvolume", tmp_path) is True
+
+ cp_calls = [
+ c for c in mock_run.call_args_list
+ if c.args and "docker" in c.args[0] and "cp" in c.args[0]
+ ]
+ assert len(cp_calls) == 1
+ assert "myvolume" in cp_calls[0].args[0][2]
+
+ def test_restore_volume_returns_false_when_docker_cp_fails(
+ self, mock_docker_client, mock_subprocess, tmp_path
+ ):
+ mock_run, _ = mock_subprocess
+ vol_dir = tmp_path / "volumes" / "myvolume"
+ vol_dir.mkdir(parents=True)
+ mock_docker_client.volumes.get.side_effect = APIError("not found")
+ mock_docker_client.volumes.create.return_value = MagicMock()
+ temp_container = MagicMock()
+ mock_docker_client.containers.run.return_value = temp_container
+ mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="copy failed")
+
+ dr = make_restore(mock_docker_client)
+ assert dr.restore_volume("myvolume", tmp_path) is False
+ temp_container.stop.assert_called_once()
+ temp_container.remove.assert_called_once()
+
+ def test_existing_volume_preserved_when_preflight_copy_fails(
+ self, mock_docker_client, mock_subprocess, tmp_path
+ ):
+ mock_run, _ = mock_subprocess
+ vol_dir = tmp_path / "volumes" / "myvolume"
+ vol_dir.mkdir(parents=True)
+ existing_vol = MagicMock()
+ staging_vol = MagicMock()
+ mock_docker_client.volumes.get.return_value = existing_vol
+ mock_docker_client.volumes.create.return_value = staging_vol
+ temp_container = MagicMock()
+ mock_docker_client.containers.run.return_value = temp_container
+ mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="copy failed")
+
+ dr = make_restore(mock_docker_client)
+ assert dr.restore_volume("myvolume", tmp_path) is False
+
+ existing_vol.remove.assert_not_called()
+ staging_vol.remove.assert_called_once()
+
+
+class TestVolumeBackupDiscovery:
+ def test_list_volume_backup_names_includes_archives(self, tmp_path):
+ volumes_dir = tmp_path / "volumes"
+ volumes_dir.mkdir()
+ (volumes_dir / "plain").mkdir()
+ (volumes_dir / "gzipped.tar.gz").write_bytes(b"")
+ (volumes_dir / "bzip.tar.bz2").write_bytes(b"")
+ (volumes_dir / "xzipped.tar.xz").write_bytes(b"")
+ (volumes_dir / "ignored.txt").write_text("")
+
+ assert list_volume_backup_names(tmp_path) == [
+ "bzip",
+ "gzipped",
+ "plain",
+ "xzipped",
+ ]
+
# ---------------------------------------------------------------------------
# TestRestoreNetwork
@@ -412,6 +495,50 @@ def run_side_effect(*args, **kwargs):
# Both volumes should be in results
assert "vol_ok" in result["volumes"] or "vol_fail" in result["volumes"]
+ def test_manifest_hash_mismatch_fails_before_restore(
+ self, mock_docker_client, mock_subprocess, tmp_path
+ ):
+ vol_dir = tmp_path / "volumes" / "data"
+ vol_dir.mkdir(parents=True)
+ (vol_dir / "file.txt").write_text("original")
+ scope = Config(config_path=None).scope
+ generate_backup_manifest(tmp_path, scope)
+ (vol_dir / "file.txt").write_text("modified")
+
+ dr = make_restore(mock_docker_client)
+ result = dr.restore_backup(
+ tmp_path,
+ containers=None,
+ volumes=["data"],
+ networks=None,
+ )
+
+ assert result["volumes"] == {}
+ assert result["errors"] == ["Manifest file hash mismatch: volumes/data/file.txt"]
+ mock_docker_client.volumes.create.assert_not_called()
+
+ def test_manifest_extra_file_fails_before_restore(
+ self, mock_docker_client, mock_subprocess, tmp_path
+ ):
+ vol_dir = tmp_path / "volumes" / "data"
+ vol_dir.mkdir(parents=True)
+ (vol_dir / "file.txt").write_text("original")
+ scope = Config(config_path=None).scope
+ generate_backup_manifest(tmp_path, scope)
+ (vol_dir / "extra.txt").write_text("unexpected")
+
+ dr = make_restore(mock_docker_client)
+ result = dr.restore_backup(
+ tmp_path,
+ containers=None,
+ volumes=["data"],
+ networks=None,
+ )
+
+ assert result["volumes"] == {}
+ assert result["errors"] == ["Manifest file not listed: volumes/data/extra.txt"]
+ mock_docker_client.volumes.create.assert_not_called()
+
# ---------------------------------------------------------------------------
# TestDecryptBackupDirectory
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..80e92b8
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,761 @@
+version = 1
+revision = 3
+requires-python = ">=3.12"
+
+[[package]]
+name = "bbackup"
+version = "1.8.0"
+source = { editable = "." }
+dependencies = [
+ { name = "click" },
+ { name = "cryptography" },
+ { name = "docker" },
+ { name = "paramiko" },
+ { name = "pyyaml" },
+ { name = "requests" },
+ { name = "rich" },
+]
+
+[package.optional-dependencies]
+management = [
+ { name = "gitpython" },
+]
+
+[package.dev-dependencies]
+dev = [
+ { name = "pytest" },
+ { name = "pytest-cov" },
+ { name = "pytest-mock" },
+ { name = "ruff" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "click", specifier = ">=8.1.7" },
+ { name = "cryptography", specifier = ">=41.0.0" },
+ { name = "docker", specifier = ">=7.0.0" },
+ { name = "gitpython", marker = "extra == 'management'", specifier = ">=3.1.0" },
+ { name = "paramiko", specifier = ">=3.4.0" },
+ { name = "pyyaml", specifier = ">=6.0.1" },
+ { name = "requests", specifier = ">=2.31.0" },
+ { name = "rich", specifier = ">=13.7.0" },
+]
+provides-extras = ["management"]
+
+[package.metadata.requires-dev]
+dev = [
+ { name = "pytest", specifier = ">=8.0" },
+ { name = "pytest-cov", specifier = ">=5.0" },
+ { name = "pytest-mock", specifier = ">=3.14" },
+ { name = "ruff", specifier = ">=0.8.0" },
+]
+
+[[package]]
+name = "bcrypt"
+version = "5.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be", size = 481806, upload-time = "2025-09-25T19:49:05.102Z" },
+ { url = "https://files.pythonhosted.org/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2", size = 268626, upload-time = "2025-09-25T19:49:06.723Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f", size = 271853, upload-time = "2025-09-25T19:49:08.028Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86", size = 269793, upload-time = "2025-09-25T19:49:09.727Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23", size = 289930, upload-time = "2025-09-25T19:49:11.204Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2", size = 272194, upload-time = "2025-09-25T19:49:12.524Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83", size = 269381, upload-time = "2025-09-25T19:49:14.308Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746", size = 271750, upload-time = "2025-09-25T19:49:15.584Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e", size = 303757, upload-time = "2025-09-25T19:49:17.244Z" },
+ { url = "https://files.pythonhosted.org/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d", size = 306740, upload-time = "2025-09-25T19:49:18.693Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba", size = 334197, upload-time = "2025-09-25T19:49:20.523Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41", size = 352974, upload-time = "2025-09-25T19:49:22.254Z" },
+ { url = "https://files.pythonhosted.org/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861", size = 148498, upload-time = "2025-09-25T19:49:24.134Z" },
+ { url = "https://files.pythonhosted.org/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e", size = 145853, upload-time = "2025-09-25T19:49:25.702Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5", size = 139626, upload-time = "2025-09-25T19:49:26.928Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" },
+ { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" },
+ { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" },
+ { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" },
+ { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" },
+ { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" },
+ { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" },
+ { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" },
+ { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" },
+ { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" },
+ { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" },
+ { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" },
+ { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" },
+ { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" },
+ { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" },
+ { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" },
+ { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" },
+ { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" },
+ { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" },
+ { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" },
+ { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" },
+ { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.5.20"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
+]
+
+[[package]]
+name = "cffi"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
+ { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
+ { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
+ { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
+ { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
+ { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
+ { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
+ { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
+ { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
+ { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
+ { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
+ { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
+ { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
+ { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" },
+ { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" },
+ { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" },
+ { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" },
+ { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" },
+ { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },
+ { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },
+ { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },
+ { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },
+ { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },
+ { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
+ { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
+ { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
+ { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
+ { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
+ { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
+ { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
+ { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
+ { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
+ { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
+ { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
+ { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
+ { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
+ { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
+]
+
+[[package]]
+name = "click"
+version = "8.4.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" },
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "coverage"
+version = "7.14.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3d/b7/bdbb725ba02c5b42825b200c940f38b7a54fcad24627b7192f78f8110d76/coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c", size = 220022, upload-time = "2026-05-26T20:39:03.702Z" },
+ { url = "https://files.pythonhosted.org/packages/72/81/fdc0898a55c6219223291ec1a1fe89966ef212ce82276aa0899df84b5de0/coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c", size = 220379, upload-time = "2026-05-26T20:39:05.381Z" },
+ { url = "https://files.pythonhosted.org/packages/de/72/de048c4a25e13bce59ac6a339351c10bdf2515e07459afcdaf04dc3143a2/coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b", size = 251888, upload-time = "2026-05-26T20:39:07.367Z" },
+ { url = "https://files.pythonhosted.org/packages/28/30/300c343f68beb9d4cbb64ec81e58c5b6b80b56927f72d2b38654ac26e013/coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6", size = 254624, upload-time = "2026-05-26T20:39:09.037Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/ed/7b25642496e8170b6bac14adce00537c6e5fa2d586159401a4de3e8b49e6/coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37", size = 255739, upload-time = "2026-05-26T20:39:10.889Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/a2/abd210b8c4e29c24e4624916db97bb519097a91034aaeb767f937e7da794/coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad", size = 257998, upload-time = "2026-05-26T20:39:12.722Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/24/7c50beed3792fe62f6ce0545c6686ce83379719e2c0276179333d97eae92/coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84", size = 252296, upload-time = "2026-05-26T20:39:14.259Z" },
+ { url = "https://files.pythonhosted.org/packages/15/05/0f874628ebcbfc77ead559ff210281ef06a97db08481832e7dd39274a135/coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54", size = 253658, upload-time = "2026-05-26T20:39:15.923Z" },
+ { url = "https://files.pythonhosted.org/packages/99/6f/ca6ad067364b337ef997802115e7ecad2abd2248b05471464b0dea02b4d4/coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7", size = 251803, upload-time = "2026-05-26T20:39:17.537Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/30/b9b4d377cd9f40baf228068f5a81faf8450c6228503011bd499708483a50/coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9", size = 255873, upload-time = "2026-05-26T20:39:19.414Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/21/7c721a9e5e6bb88547d30a787aefb97512d3f54c1324c7488d9b3743f7f9/coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02", size = 251372, upload-time = "2026-05-26T20:39:21.169Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/8c/f8ae5a2200130e1503cd7661a6cd3b2b7bacef98277fbf3571fb13f8b766/coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a", size = 253245, upload-time = "2026-05-26T20:39:23.097Z" },
+ { url = "https://files.pythonhosted.org/packages/34/62/70a9024672a5f6910517d9628c52c9afbdd3cf8f46426af52bb148a56fff/coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1", size = 222567, upload-time = "2026-05-26T20:39:24.868Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/81/8b7cd386839b039ebe1855733b9f9449a8dec5d79564018234f185a7fa70/coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e", size = 223372, upload-time = "2026-05-26T20:39:26.603Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/ba/b44d472022f620d289d95fa830143235c0c36461c6f2437ea8d51e5481ed/coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a", size = 221989, upload-time = "2026-05-26T20:39:28.242Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/9e/5f6d56327c62b185225d145191c607e07515294a0aa6338e58805cd4a5ac/coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793", size = 220044, upload-time = "2026-05-26T20:39:29.902Z" },
+ { url = "https://files.pythonhosted.org/packages/75/92/e82aca356744cbbc0f77a0b623e38918c1872361963413a3bab5d0340393/coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d", size = 220412, upload-time = "2026-05-26T20:39:31.561Z" },
+ { url = "https://files.pythonhosted.org/packages/27/c9/385bde0bf7ed0f4bf3a7ee5367060a86b5d218718cfd6fb943c0f836b34f/coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247", size = 251412, upload-time = "2026-05-26T20:39:33.337Z" },
+ { url = "https://files.pythonhosted.org/packages/51/8c/23faf6a2343a0d17f960a4bd56c43bc7eb4cf312f774dd6ceebd82c7d8fc/coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d", size = 254008, upload-time = "2026-05-26T20:39:35.009Z" },
+ { url = "https://files.pythonhosted.org/packages/42/06/36f4aa9ca8a815e6036156e80706a67828bb97bd826948244f6996dda957/coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b", size = 255241, upload-time = "2026-05-26T20:39:36.71Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/79/95266316352f90f6b1c6736bb413302edfde2453fb32422d3911642691b3/coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be", size = 257373, upload-time = "2026-05-26T20:39:38.412Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/9c/58316d1f66c488b5fca8a0eb3e98348807813efa8a0d0833b9021be27488/coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43", size = 251635, upload-time = "2026-05-26T20:39:40.268Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/5a/ca2398a568e16fed7bb713e84ba3603a7164fb65779abe645c565ec890d5/coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901", size = 253373, upload-time = "2026-05-26T20:39:42.145Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/2c/0396562c32deaebe7be51d865b3a41e9a87d7561acafe1a28f53b07e019a/coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff", size = 251341, upload-time = "2026-05-26T20:39:43.907Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/8f/a94f9221184c9cae1ee115820e3798e48b6b17777a9f19e46fb9a0c8dc74/coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4", size = 255497, upload-time = "2026-05-26T20:39:46.166Z" },
+ { url = "https://files.pythonhosted.org/packages/71/69/505d70e47db1eaebcd002c39759707621ef184cd6b1ae084d9f41293f323/coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d", size = 251159, upload-time = "2026-05-26T20:39:48.03Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/aa/58681c383aa33a9d2ed40a02d7a22fbf780d1fa4d575396365777828198c/coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33", size = 252934, upload-time = "2026-05-26T20:39:49.872Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/fd/11c928cd6bdffc7074bb5965c173d9ebf517fb00205e1da524b98d29ef92/coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c", size = 222584, upload-time = "2026-05-26T20:39:51.68Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/92/fb416fc26d340dcba19518c418d6048e913186e17243982c5e435e41fa7a/coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416", size = 223394, upload-time = "2026-05-26T20:39:53.472Z" },
+ { url = "https://files.pythonhosted.org/packages/73/c6/02d56e3867972f77d5036de924643f26c056e848f00452cafb4dbc3c29b4/coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42", size = 222015, upload-time = "2026-05-26T20:39:55.374Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/9e/fcc77914050df73f7662fa1f00902774c79c075a8388ab334074574bf77e/coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d", size = 220733, upload-time = "2026-05-26T20:39:57.189Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/67/2963cbdaf5cbadec44efa3a1e39eaa1f02df4079585f05387607a221e126/coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5", size = 221086, upload-time = "2026-05-26T20:39:59.019Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/c5/8701645574e11881f2f47d8930f98bc48b5d43b25eb5b4430dfc4a2f9f48/coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52", size = 262381, upload-time = "2026-05-26T20:40:00.822Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/28/7a64d73598263e0c5abd5084211a8474488d31b3c552ff531c719dfcff62/coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a", size = 264458, upload-time = "2026-05-26T20:40:02.506Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/d8/4969179db9f7eb4df218e69540adf829d1c835f59452513d065d15446802/coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a", size = 266884, upload-time = "2026-05-26T20:40:04.421Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/78/a45d5794dbc9bafd97afc96a4377c86c7820d78b6cf51b89bc1d4e919275/coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2", size = 268022, upload-time = "2026-05-26T20:40:06.298Z" },
+ { url = "https://files.pythonhosted.org/packages/21/cb/4f5e354e9e3e67af96bd4e57113e6db6b22298c7168b13eec408a549903d/coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e", size = 261631, upload-time = "2026-05-26T20:40:08.226Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/49/eced49af4cb996d5d8b7e94e736175c513e4facd3398507b89892b4326d8/coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d", size = 264443, upload-time = "2026-05-26T20:40:10.137Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/d8/5603a88a7c5913a6b54f6cb1a8c46f7b39cbb30f27cd3f492908da09b2d7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb", size = 262069, upload-time = "2026-05-26T20:40:11.999Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/59/2ae3cb79da554a06c8619d6c88ea19dd1e4aed4b834b6a83bb1fa243bdc5/coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d", size = 265780, upload-time = "2026-05-26T20:40:13.858Z" },
+ { url = "https://files.pythonhosted.org/packages/af/5f/b130c1dc999031f2648bd25317fbce505ad8d5562079b4ed81e736a84967/coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69", size = 260970, upload-time = "2026-05-26T20:40:16.142Z" },
+ { url = "https://files.pythonhosted.org/packages/87/d1/ec13ccddeb48ec963bdfa72a11224bac2584bd045ba13beca82f8113e9c7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54", size = 263157, upload-time = "2026-05-26T20:40:18.382Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/c2/cd91ead503045161092d3845f7bb95ea2f25131ce96d3e314dd835d91b9c/coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1", size = 223259, upload-time = "2026-05-26T20:40:20.381Z" },
+ { url = "https://files.pythonhosted.org/packages/71/9f/1e28d97e6bd2c76b07f38b7c02870f1371255ff6717f54eca578fcbbdd0e/coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce", size = 224320, upload-time = "2026-05-26T20:40:22.316Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/e0/d936e908f0e1efa55e52b91e01b52f1055cef5e1ab2718493390ed8e2fb8/coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1", size = 222577, upload-time = "2026-05-26T20:40:24.894Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/34/fc2f101b151af3799a101f0550b0454aa008afdc0add677394ec4aa8ea10/coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee", size = 220091, upload-time = "2026-05-26T20:40:27.249Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/a7/1ebae2ab5b961b5c79bb09fe7b3ac99edb190d8be4a8c510b2cf66f46468/coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500", size = 220421, upload-time = "2026-05-26T20:40:30.084Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/90/92aca9cf0acc95123c96cd1eb1f08917897a7f5dee01e15738922971ec31/coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906", size = 251466, upload-time = "2026-05-26T20:40:32.542Z" },
+ { url = "https://files.pythonhosted.org/packages/26/2b/78048cbe3b999f6cbf9cc0d90abba6a88a3e0863a8c1c6cbc762f3f8802f/coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42", size = 253973, upload-time = "2026-05-26T20:40:34.473Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/21/c2e33b29d1cfde484a19d437afc343c6cd30b08d78cbbf9f5aff14e57b2b/coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8", size = 255318, upload-time = "2026-05-26T20:40:38.154Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/ee/aad2f108d63b769121005302f16bf66db8625c88ceaba466942e09a2607e/coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851", size = 257633, upload-time = "2026-05-26T20:40:40.164Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/f8/11a2c29b4fd76d9849f81d0bb812ec0017a9396df3217214e38934a8c837/coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034", size = 251488, upload-time = "2026-05-26T20:40:42.631Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/b8/9a5820de4b8ac2b71d85e3b5fb49108d7469c665f0e2ad0dd7569023e305/coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c", size = 253329, upload-time = "2026-05-26T20:40:45.208Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/ff/f33e4823667e27548e8fd8df44217515303f9808d0ff29817db56f87d990/coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36", size = 251291, upload-time = "2026-05-26T20:40:47.502Z" },
+ { url = "https://files.pythonhosted.org/packages/68/9b/489db0ebb209054766b90a9014a45f6d26eb724c02ec21311c3733b5a644/coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5", size = 255564, upload-time = "2026-05-26T20:40:49.372Z" },
+ { url = "https://files.pythonhosted.org/packages/27/b5/16bc2d4c2409b23c7737edb68c83bc89e345f378050549fe1d75ac7d34d5/coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4", size = 251107, upload-time = "2026-05-26T20:40:51.677Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/0c/2629997469a00cd069d588a41c9dc887610f2775ae89d250c4791e65272a/coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d", size = 252764, upload-time = "2026-05-26T20:40:54.267Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/ee/f78d63c8f079e0d7211c7e2401fa17e311514534ba61bae03e4b287ce4ab/coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee", size = 222837, upload-time = "2026-05-26T20:40:56.496Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/b9/be539854f93a70dfbeec69117f33ec70dc42ff0b65b5b07ab8d40d04228e/coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7", size = 223650, upload-time = "2026-05-26T20:40:58.351Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/9e/24e2842fef40f35ac82ba3a7719c8023d011bf3bf652d0675316a9d088a1/coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343", size = 222218, upload-time = "2026-05-26T20:41:00.321Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/1d/ac0a9df5fe31c1e8bdd658074905fc12844a05c1a7e3fdb8417e97c31e23/coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1", size = 220822, upload-time = "2026-05-26T20:41:02.281Z" },
+ { url = "https://files.pythonhosted.org/packages/32/cf/f964fd9aff20323f9f1a726c97135f8a76bcd87b92dad141a456a43f3c64/coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b", size = 221084, upload-time = "2026-05-26T20:41:04.593Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/5e/7e5ef2aba844de2b80d678619fcf0841b42e3f37f16411226f3fe4c1016f/coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474", size = 262454, upload-time = "2026-05-26T20:41:06.641Z" },
+ { url = "https://files.pythonhosted.org/packages/64/62/75809bded87015cc4935524218a2a8ed8dd1a8498bfed30a2f4f7a4b4d34/coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86", size = 264578, upload-time = "2026-05-26T20:41:08.556Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/42/d33392dc14633525012d2d504fa1a33b05538bf535f5c1d64675e5754b78/coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e", size = 266981, upload-time = "2026-05-26T20:41:10.824Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/49/0157c4428c2aca7f1e09d5565930586fd5ae36f1655f08b0daa7cf1fcae1/coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65", size = 268112, upload-time = "2026-05-26T20:41:12.966Z" },
+ { url = "https://files.pythonhosted.org/packages/96/26/86b9ce71f4092b1ed325ce1421698081df1286b833400b6836912834d6e0/coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e", size = 261558, upload-time = "2026-05-26T20:41:15Z" },
+ { url = "https://files.pythonhosted.org/packages/20/4c/c311210c5472cf5401d8422b0d7812cdd520f24417673afabda6c323faca/coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8", size = 264447, upload-time = "2026-05-26T20:41:17.369Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/71/59513f8710ed3e6b0ac0a050a5b7e977bb9c9e880354863b5d00d8809256/coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07", size = 262048, upload-time = "2026-05-26T20:41:19.309Z" },
+ { url = "https://files.pythonhosted.org/packages/84/8d/bceed32dc494f5bbf50f775cd2e78ca814953942b5ea28d3c1c3ac316f14/coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de", size = 265781, upload-time = "2026-05-26T20:41:21.559Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/c5/9348fe40dbfd4991aaf78df2c6c3098bfb2cc834d1fd362a64b4efef855a/coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890", size = 260896, upload-time = "2026-05-26T20:41:23.428Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/92/1ea0f03929da7cf87206b1fa24f4c8e9c158be0455481af29ec0a1f3503f/coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd", size = 263214, upload-time = "2026-05-26T20:41:25.419Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/a9/b2493c054c0e01a643266742ab45e15744e60743f9260cd930c7142b1124/coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e", size = 223624, upload-time = "2026-05-26T20:41:27.795Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/bd/3e1e6a57fccd2d7c83fcdf338e93ba98eb85c6e877dd34731ac585375490/coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c", size = 224728, upload-time = "2026-05-26T20:41:30.098Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/d7/31066cf1d2f0c6c797fce911bcfa01dd35642dc6da992a950256097c5860/coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af", size = 222752, upload-time = "2026-05-26T20:41:32.123Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" },
+]
+
+[[package]]
+name = "cryptography"
+version = "48.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/3d/01f6dd9190170a5a241e0e98c2d04be3664a9e6f5b9b872cde63aff1c3dd/cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6", size = 8001587, upload-time = "2026-05-04T22:57:36.803Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" },
+ { url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" },
+ { url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" },
+ { url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" },
+ { url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" },
+ { url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" },
+ { url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" },
+ { url = "https://files.pythonhosted.org/packages/04/70/e5a1b41d325f797f39427aa44ef8baf0be500065ab6d8e10369d850d4a4f/cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4", size = 3294868, upload-time = "2026-05-04T22:58:06.467Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/ac/8ac51b4a5fc5932eb7ee5c517ba7dc8cd834f0048962b6b352f00f41ebf9/cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7", size = 3817107, upload-time = "2026-05-04T22:58:08.845Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/84/70e3feea9feea87fd7cbe77efb2712ae1e3e6edf10749dc6e95f4e60e455/cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec", size = 7986556, upload-time = "2026-05-04T22:58:11.172Z" },
+ { url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" },
+ { url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" },
+ { url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" },
+ { url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" },
+ { url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" },
+ { url = "https://files.pythonhosted.org/packages/75/54/cc6d0f3deac3e81c7f847e8a189a12b6cdd65059b43dad25d4316abd849a/cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f", size = 3270954, upload-time = "2026-05-04T22:58:38.791Z" },
+ { url = "https://files.pythonhosted.org/packages/49/67/cc947e288c0758a4e5473d1dcb743037ab7785541265a969240b8885441a/cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12", size = 3797313, upload-time = "2026-05-04T22:58:40.746Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/63/61d4a4e1c6b6bab6ce1e213cd36a24c415d90e76d78c5eb8577c5541d2e8/cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86", size = 7983482, upload-time = "2026-05-04T22:58:43.769Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" },
+ { url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" },
+ { url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" },
+ { url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" },
+ { url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/23/6e6f32143ab5d8b36ca848a502c4bcd477ae75b9e1677e3530d669062578/cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd", size = 3279117, upload-time = "2026-05-04T22:59:12.019Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/9a/0fea98a70cf1749d41d738836f6349d97945f7c89433a259a6c2642eefeb/cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8", size = 3792100, upload-time = "2026-05-04T22:59:14.884Z" },
+]
+
+[[package]]
+name = "docker"
+version = "7.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pywin32", marker = "sys_platform == 'win32'" },
+ { name = "requests" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/91/9b/4a2ea29aeba62471211598dac5d96825bb49348fa07e906ea930394a83ce/docker-7.1.0.tar.gz", hash = "sha256:ad8c70e6e3f8926cb8a92619b832b4ea5299e2831c14284663184e200546fa6c", size = 117834, upload-time = "2024-05-23T11:13:57.216Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" },
+]
+
+[[package]]
+name = "gitdb"
+version = "4.0.12"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "smmap" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" },
+]
+
+[[package]]
+name = "gitpython"
+version = "3.1.50"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "gitdb" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/33/f6/354ae6491228b5eb40e10d89c4d13c651fe1cf7556e35ebdded50cff57ce/gitpython-3.1.50.tar.gz", hash = "sha256:80da2d12504d52e1f998772dc5baf6e553f8d2fcfe1fcc226c9d9a2ee3372dcc", size = 219798, upload-time = "2026-05-06T04:01:26.571Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/20/7a/1c6e3562dfd8950adbb11ffbc65d21e7c89d01a6e4f137fa981056de25c5/gitpython-3.1.50-py3-none-any.whl", hash = "sha256:d352abe2908d07355014abdd21ddf798c2a961469239afec4962e9da884858f9", size = 212507, upload-time = "2026-05-06T04:01:23.799Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.17"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b9/28/99c51f664567218d824af024c0251650fb27e4ca066df188dab0769c5b91/idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f", size = 196048, upload-time = "2026-05-28T14:32:38.55Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/a7/f76514cc40ad6234098ecdebda08732d75964776c51a42845b7da10649e2/idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c", size = 65316, upload-time = "2026-05-28T14:32:37.035Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "invoke"
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/33/f6/227c48c5fe47fa178ccf1fda8f047d16c97ba926567b661e9ce2045c600c/invoke-3.0.3.tar.gz", hash = "sha256:437b6a622223824380bfb4e64f612711a6b648c795f565efc8625af66fb57f0c", size = 343419, upload-time = "2026-04-07T15:17:48.307Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/de/bbc12563bbf979618d17625a4e753ff7a078523e28d870d3626daa97261a/invoke-3.0.3-py3-none-any.whl", hash = "sha256:f11327165e5cbb89b2ad1d88d3292b5113332c43b8553b494da435d6ec6f5053", size = 160958, upload-time = "2026-04-07T15:17:46.875Z" },
+]
+
+[[package]]
+name = "markdown-it-py"
+version = "4.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mdurl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
+]
+
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
+]
+
+[[package]]
+name = "paramiko"
+version = "5.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "bcrypt" },
+ { name = "cryptography" },
+ { name = "invoke" },
+ { name = "pynacl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/62/93/dcc25d52f49022ae6175d15e6bd751f1acc99b98bc61fc55e5155a7be2e7/paramiko-5.0.0.tar.gz", hash = "sha256:36763b5b95c2a0dcfdf1abc48e48156ee425b21efe2f0e787c2dd5a95c0e5e79", size = 1548586, upload-time = "2026-05-09T18:28:52.256Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/82/5b/eadf6d45de38d30ab603f49393b6cd2cbe7e233af8cf90197e32782b68a9/paramiko-5.0.0-py3-none-any.whl", hash = "sha256:b7044611c30140d9a75261653210e2002977b71a0497ff3ba0d98d7edbf62f7c", size = 208919, upload-time = "2026-05-09T18:28:50.295Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "pycparser"
+version = "3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+]
+
+[[package]]
+name = "pynacl"
+version = "1.6.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" },
+ { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" },
+ { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" },
+ { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" },
+ { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" },
+ { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" },
+ { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" },
+ { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" },
+ { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" },
+ { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" },
+ { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" },
+ { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "iniconfig" },
+ { name = "packaging" },
+ { name = "pluggy" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
+]
+
+[[package]]
+name = "pytest-cov"
+version = "7.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "coverage" },
+ { name = "pluggy" },
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
+]
+
+[[package]]
+name = "pytest-mock"
+version = "3.15.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pytest" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
+]
+
+[[package]]
+name = "pywin32"
+version = "311"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" },
+ { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" },
+ { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
+ { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
+ { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
+ { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
+ { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
+ { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
+ { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
+ { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
+ { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
+ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
+ { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
+ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
+ { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
+ { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
+ { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
+ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
+]
+
+[[package]]
+name = "requests"
+version = "2.34.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
+]
+
+[[package]]
+name = "rich"
+version = "15.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markdown-it-py" },
+ { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
+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.15.15"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" },
+ { url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" },
+ { url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" },
+ { url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" },
+ { url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" },
+ { url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" },
+]
+
+[[package]]
+name = "smmap"
+version = "5.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
+]