pivot to Python: replace Kotlin/JVM with stdlib zipapp
CI / test (3.10) (push) Successful in 40s
CI / test (3.11) (push) Successful in 19s
CI / test (3.12) (push) Successful in 23s
CI / build-pyz (push) Successful in 4s
CI / release (push) Has been skipped

Reasons stacked up:
  - AV: unsigned JARs that auto-download binaries + upload files trigger
    Windows Defender false-positives more often than Python scripts
    invoked by code-signed python.exe.
  - Qt UI option: PySide6 opens a path to a real Qt UI (matching Prism's
    look) if needed later. JVM Qt bindings are abandoned.
  - frazclient already needs Python; inlining as 'import cloud_sync' is
    zero overhead vs the launcher always shelling out to java.

Implementation:
  - cloud_sync package: cli.py (argparse), creds.py, scope.py,
    restic.py (binary discovery + auto-download + sha256 verify),
    sync.py (pull/push subprocess restic).
  - pyproject.toml with hatchling backend; pip-installable.
  - Makefile builds cloud-sync.pyz via python -m zipapp (~53 KB).
  - 33 pytest tests, stdlib only on runtime.
  - CI workflow runs pytest matrix (3.10/3.11/3.12) + builds pyz.
  - DESIGN.md + README.md updated to reflect Python.

E2E verified against local restic-rest-server:
  pull empty → push initial → rm -rf local → pull restores → modify+push
  creates second snapshot → client forget --prune blocked by --append-only.

Throws away ~565 LOC of Kotlin (and 18 jar tests) committed earlier in
this same session. Net result is ~250 LOC Python + 33 tests = smaller
and more aligned with the rest of the stack.
This commit is contained in:
2026-06-03 01:11:47 +02:00
parent 171ea8f47a
commit ffdfb1f9b6
32 changed files with 1056 additions and 1343 deletions
+223
View File
@@ -0,0 +1,223 @@
"""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 urllib.request
import zipfile
from dataclasses import dataclass
from pathlib import Path
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,
) -> tuple[int, str]:
"""Run restic. Inherits stderr to caller's terminal for live progress.
Returns (returncode, captured_stdout)."""
merged_env = dict(os.environ)
if env:
merged_env.update(env)
p = subprocess.run( # noqa: S603 — controlled invocation
[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.returncode, p.stdout
# ---------------------------------------------------------------------------
# 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}")