Files
cloud-sync/cloud_sync/restic.py
T
claude-timemachine fe26ed309c
CI / test (3.10) (push) Successful in 8s
CI / test (3.11) (push) Successful in 6s
CI / test (3.12) (push) Successful in 7s
CI / build-pyz (push) Successful in 4s
CI / release (push) Has been skipped
feat(ui): Qt progress window with Prism-Launcher-inspired dark palette
cloud-sync now ships a real Qt UI alongside the tkinter fallback.

Architecture:
  - HeadlessProgress: --no-gui path, plain stdout
  - TkProgressWindow: stdlib fallback when Qt isn't installed
  - QtProgressWindow: preferred path; supports both PySide6 and PyQt6
    (interchangeable APIs for our subset)

The factory in ui.py picks Qt → tkinter → headless. Tk stays so the
zipapp still works on bare Python with no extras.

Threading: QApplication runs on the main thread (started by run_with
via QDialog.exec). The restic worker runs on a daemon threading.Thread.
Cross-thread UI updates go via a Signal on a bridge QObject so Qt
auto-marshals them onto the main thread via a queued connection.

Cancellation: WM close + Cancel button both set a flag. sync.pull/push
pass ui.is_cancelled as restic.run's cancel_check; the subprocess gets
killed and returns -1 → exit 1.

Theme: Fusion style + Prism's dark palette (RGB values copied as facts
from PrismLauncher's DarkTheme.cpp). Override with PRISM_THEME=off.

Pyz size went 20 KB → 36 KB (added ui.py + ui_qt.py).
33 tests still green.
2026-06-04 23:12:58 +02:00

262 lines
8.0 KiB
Python

"""restic binary discovery + auto-download + invocation.
Discovery order:
1. ``--restic-binary <path>`` (explicit override)
2. ``<pack-folder>/.cloud-sync/restic-<RESTIC_VERSION>`` (pinned cached copy)
3. ``$PATH`` (only if version matches RESTIC_VERSION)
4. download from GitHub releases (unless ``--no-download``)
The version is pinned because repos written by one restic version can have
features another version can't read. Cache the pinned binary per-instance
so deleting the instance dir wipes everything cloud-sync owns.
"""
from __future__ import annotations
import bz2
import hashlib
import os
import platform
import re
import shutil
import stat
import subprocess
import sys
import tempfile
import time
import urllib.request
import zipfile
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
from .cli import Args
RESTIC_VERSION = "0.18.0"
RELEASE_TAG = f"v{RESTIC_VERSION}"
_HTTP_TIMEOUT = 30
@dataclass(frozen=True)
class Platform:
os_tag: str # linux, darwin, windows
arch_tag: str # amd64, arm64
is_windows: bool
def _detect_platform() -> Platform:
name = platform.system().lower()
arch_raw = platform.machine().lower()
if name.startswith("linux"):
os_tag = "linux"
elif name.startswith("darwin"):
os_tag = "darwin"
elif name.startswith("windows"):
os_tag = "windows"
else:
raise RuntimeError(f"unsupported platform: {name}")
if arch_raw in ("amd64", "x86_64"):
arch_tag = "amd64"
elif arch_raw in ("aarch64", "arm64"):
arch_tag = "arm64"
else:
raise RuntimeError(f"unsupported architecture: {arch_raw}")
return Platform(os_tag, arch_tag, os_tag == "windows")
def _binary_filename(plat: Platform) -> str:
suffix = ".exe" if plat.is_windows else ""
return f"restic-{RESTIC_VERSION}{suffix}"
def resolve_binary(args: Args) -> Path:
"""Return a usable restic binary path, downloading if needed."""
if args.restic_binary is not None:
if not args.restic_binary.exists():
raise FileNotFoundError(
f"--restic-binary path does not exist: {args.restic_binary}"
)
return args.restic_binary
plat = _detect_platform()
cache_dir = args.pack_folder / ".cloud-sync"
cached = cache_dir / _binary_filename(plat)
if cached.exists() and os.access(cached, os.X_OK):
return cached
# Try $PATH only when version matches exactly
system = _find_system_binary_matching_version()
if system is not None:
return system
if not args.allow_download:
raise RuntimeError(
f"no usable restic binary at {cached} or on $PATH, "
f"and --no-download disabled auto-fetch"
)
cache_dir.mkdir(parents=True, exist_ok=True)
_download_restic_to(cached, plat)
return cached
def run(
binary: Path,
args: list[str],
env: dict[str, str] | None = None,
cwd: Path | None = None,
timeout: int = 900,
cancel_check: Callable[[], bool] | None = None,
) -> tuple[int, str]:
"""Run restic. Inherits stderr to caller's terminal for live progress.
Returns (returncode, captured_stdout).
When cancel_check is supplied, polls every 100 ms; if it returns True,
kills restic and returns ``(-1, "")``.
"""
merged_env = dict(os.environ)
if env:
merged_env.update(env)
if cancel_check is None:
p_run = subprocess.run( # noqa: S603
[str(binary), *args],
cwd=str(cwd) if cwd else None,
env=merged_env,
stdout=subprocess.PIPE,
stderr=sys.stderr,
text=True,
timeout=timeout,
check=False,
)
return p_run.returncode, p_run.stdout
# Cancel-capable path: spawn + poll
p = subprocess.Popen( # noqa: S603
[str(binary), *args],
cwd=str(cwd) if cwd else None,
env=merged_env,
stdout=subprocess.PIPE,
stderr=sys.stderr,
text=True,
)
deadline = time.monotonic() + timeout
while p.poll() is None:
if cancel_check():
p.kill()
try:
p.wait(timeout=5)
except subprocess.TimeoutExpired:
pass
return -1, ""
if time.monotonic() > deadline:
p.kill()
try:
p.wait(timeout=5)
except subprocess.TimeoutExpired:
pass
raise subprocess.TimeoutExpired([str(binary), *args], timeout)
time.sleep(0.1)
out = p.stdout.read() if p.stdout else ""
return p.returncode, out
# ---------------------------------------------------------------------------
# discovery + download helpers
# ---------------------------------------------------------------------------
def _find_system_binary_matching_version() -> Path | None:
name = "restic.exe" if _detect_platform().is_windows else "restic"
found = shutil.which(name)
if not found:
return None
path = Path(found)
return path if _binary_version(path) == RESTIC_VERSION else None
def _binary_version(path: Path) -> str | None:
try:
out = subprocess.run( # noqa: S603
[str(path), "version"],
capture_output=True, text=True, timeout=5, check=False,
)
except (subprocess.SubprocessError, OSError):
return None
text = out.stdout + out.stderr
m = re.search(r"restic\s+(\d+\.\d+\.\d+)", text)
return m.group(1) if m else None
def _download_restic_to(target: Path, plat: Platform) -> None:
ext = "zip" if plat.is_windows else "bz2"
asset = f"restic_{RESTIC_VERSION}_{plat.os_tag}_{plat.arch_tag}.{ext}"
asset_url = (
f"https://github.com/restic/restic/releases/download/"
f"{RELEASE_TAG}/{asset}"
)
sums_url = (
f"https://github.com/restic/restic/releases/download/"
f"{RELEASE_TAG}/SHA256SUMS"
)
print(
f"cloud-sync: downloading restic {RESTIC_VERSION} from {asset_url}",
file=sys.stderr,
)
with tempfile.NamedTemporaryFile(suffix=f".{ext}", delete=False) as tmp:
tmp_path = Path(tmp.name)
try:
with urllib.request.urlopen(asset_url, timeout=_HTTP_TIMEOUT) as r:
tmp_path.write_bytes(r.read())
expected = _expected_sha(sums_url, asset)
actual = _sha256_file(tmp_path)
if expected.lower() != actual.lower():
raise RuntimeError(
f"restic download sha mismatch: expected {expected}, got {actual}"
)
_decompress_into(tmp_path, target, ext)
if not plat.is_windows:
target.chmod(target.stat().st_mode | stat.S_IXUSR | stat.S_IRUSR | stat.S_IWUSR)
finally:
tmp_path.unlink(missing_ok=True)
def _expected_sha(sums_url: str, asset: str) -> str:
with urllib.request.urlopen(sums_url, timeout=_HTTP_TIMEOUT) as r:
body = r.read().decode("utf-8")
for line in body.splitlines():
line = line.strip()
if line.endswith(asset):
return line.split()[0]
raise RuntimeError(f"restic SHA256SUMS missing entry for {asset}")
def _sha256_file(path: Path) -> str:
h = hashlib.sha256()
with path.open("rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
h.update(chunk)
return h.hexdigest()
def _decompress_into(archive: Path, target: Path, ext: str) -> None:
target.parent.mkdir(parents=True, exist_ok=True)
if ext == "bz2":
with bz2.open(archive, "rb") as src, target.open("wb") as dst:
shutil.copyfileobj(src, dst)
elif ext == "zip":
with zipfile.ZipFile(archive) as zf:
inner = next(
(n for n in zf.namelist() if n.endswith("restic.exe")),
None,
)
if inner is None:
raise RuntimeError("restic.exe not found in downloaded zip")
with zf.open(inner) as src, target.open("wb") as dst:
shutil.copyfileobj(src, dst)
else:
raise RuntimeError(f"unsupported archive ext: {ext}")