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
+14
View File
@@ -0,0 +1,14 @@
"""cloud-sync — per-user state sync for Minecraft via restic.
Public API for in-process callers (e.g. frazclient):
import cloud_sync
cloud_sync.pull(url="https://cloud.tm.center", pack_folder=Path("/instance"))
cloud_sync.push(url="https://cloud.tm.center", pack_folder=Path("/instance"))
"""
from .cli import Args
from .sync import pull, push
__version__ = "0.1.0"
__all__ = ["Args", "pull", "push", "__version__"]
+11
View File
@@ -0,0 +1,11 @@
"""Entry for ``python -m cloud_sync`` and the zipapp build."""
from __future__ import annotations
import sys
from .cli import main
if __name__ == "__main__":
sys.exit(main())
+102
View File
@@ -0,0 +1,102 @@
"""CLI parsing + entry point dispatch.
Flag style mirrors packwiz-installer-bootstrap so operators wiring Prism's
PreLaunch/PostExit hooks don't relearn the surface. Supports both
``--url value`` and ``--url=value`` forms.
"""
from __future__ import annotations
import argparse
import sys
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class Args:
"""Parsed CLI args shared by both pull + push subcommands."""
url: str
pack_folder: Path
token_file: Path
restic_binary: Path | None # None → auto-discover
allow_download: bool
headless: bool
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="cloud-sync",
description="Per-user Minecraft state sync via restic.",
)
p.add_argument("--version", action="version", version="cloud-sync 0.1.0")
sub = p.add_subparsers(dest="cmd", required=True)
for name in ("pull", "push"):
sp = sub.add_parser(name, help=f"{name} player state")
sp.add_argument(
"--url", required=True,
help="cloud-svc data plane URL (e.g. https://cloud.tm.center)",
)
sp.add_argument(
"--pack-folder", default=".", type=Path,
help="Minecraft instance directory (default: cwd)",
)
sp.add_argument(
"--token-file", default=None, type=Path,
help="Token file path (default: <pack-folder>/.cloud-sync/token)",
)
sp.add_argument(
"--restic-binary", default=None, type=Path,
help="Path to a restic binary; overrides auto-discovery",
)
sp.add_argument(
"--no-download", action="store_true",
help="Don't auto-fetch restic from upstream; fail if not found locally",
)
sp.add_argument(
"-g", "--no-gui", action="store_true",
help="Headless mode (no Swing/Qt windows, restic stdout only)",
)
return p
def parse(argv: list[str]) -> tuple[str, Args]:
"""Parse argv → (subcommand, Args). Raises SystemExit on error/help."""
ns = build_parser().parse_args(argv)
pack = Path(ns.pack_folder).absolute().resolve()
token = (
Path(ns.token_file).absolute()
if ns.token_file is not None
else pack / ".cloud-sync" / "token"
)
return ns.cmd, Args(
url=ns.url,
pack_folder=pack,
token_file=token,
restic_binary=Path(ns.restic_binary).absolute() if ns.restic_binary else None,
allow_download=not ns.no_download,
headless=ns.no_gui,
)
def main(argv: list[str] | None = None) -> int:
"""CLI entrypoint. Returns exit code (0=ok, 1=user cancel, 2=error)."""
# Import here to keep CLI import light (test isolation).
from . import sync
try:
cmd, args = parse(sys.argv[1:] if argv is None else argv)
except SystemExit as e:
return int(e.code) if isinstance(e.code, int) else 2
action = {"pull": sync.pull, "push": sync.push}[cmd]
try:
return action(args)
except KeyboardInterrupt:
print("cloud-sync: cancelled", file=sys.stderr)
return 1
except Exception as e: # noqa: BLE001
print(f"cloud-sync {cmd}: {e}", file=sys.stderr)
return 2
+40
View File
@@ -0,0 +1,40 @@
"""Token file reader.
Format: ``discord_id:password`` on a single line. Whitespace tolerated.
The Discord ID is the URL path segment under cloud.tm.center/<id>/ that
restic-rest-server's --private-repos enforces against the basic-auth
username. The password is the bcrypt'd entry's plaintext AND the restic
repo encryption password (cloud-svc provisions one password covering both).
"""
from __future__ import annotations
from pathlib import Path
class CredentialsError(Exception):
"""Raised when the token file is missing or malformed."""
def read_credentials(token_file: Path) -> tuple[str, str]:
if not token_file.exists():
raise CredentialsError(
f"cloud-sync token not found at {token_file}. "
f"After /register in Discord you should have received credentials; "
f"paste them into this file as 'discord_id:password' on one line."
)
raw = token_file.read_text(encoding="utf-8").strip()
if ":" not in raw:
raise CredentialsError(
f"cloud-sync token at {token_file} malformed "
f"(expected 'discord_id:password' on one line)"
)
discord_id, password = raw.split(":", 1)
discord_id = discord_id.strip()
password = password.strip()
if not discord_id or not password:
raise CredentialsError(
f"cloud-sync token at {token_file} malformed "
f"(empty discord_id or password)"
)
return discord_id, password
+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}")
+84
View File
@@ -0,0 +1,84 @@
"""Per-distribution sync scope (include/exclude paths).
Each cloud-sync deployment ships its own ``scope.json`` that picks which
files participate in sync. Lives at ``<pack-folder>/.cloud-sync/scope.json``.
Defaults are baked in so a fresh install with no scope.json works.
"""
from __future__ import annotations
import json
import sys
from dataclasses import dataclass, field
from pathlib import Path
DEFAULT_INCLUDE: list[str] = [
"options.txt",
"optionsof.txt",
"optionsshaders.txt",
"config/",
"journeymap/data/",
"screenshots/",
]
DEFAULT_EXCLUDE: list[str] = [
".cloud-sync/", # never sync our own state dir
".cloud-token", # legacy location (pre-jar/pre-restic era)
"config/simple-mod-sync*",
"config/packwiz*",
"**/cache/",
"**/*.log",
"**/*.tmp",
]
@dataclass(frozen=True)
class Scope:
include: list[str] = field(default_factory=lambda: list(DEFAULT_INCLUDE))
exclude: list[str] = field(default_factory=lambda: list(DEFAULT_EXCLUDE))
def load(pack_folder: Path) -> Scope:
"""Read scope.json or return defaults."""
path = pack_folder / ".cloud-sync" / "scope.json"
if not path.exists():
return Scope()
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as e:
print(
f"cloud-sync: scope.json invalid ({e}); using defaults",
file=sys.stderr,
)
return Scope()
return Scope(
include=list(data.get("include", DEFAULT_INCLUDE)),
exclude=list(data.get("exclude", DEFAULT_EXCLUDE)),
)
def materialize_for_restic(pack_folder: Path, scope: Scope) -> tuple[Path, Path]:
"""Write files-from + exclude-from text files restic can consume.
Files include directories; restic recurses into them. Exclude patterns
are matched against file paths during the walk.
"""
state_dir = pack_folder / ".cloud-sync"
state_dir.mkdir(parents=True, exist_ok=True)
files_from = state_dir / "files-from.txt"
exclude_from = state_dir / "exclude-from.txt"
files_from.write_text(
"\n".join(_trim_trailing_slash(p) for p in scope.include) + "\n",
encoding="utf-8",
)
exclude_from.write_text(
"\n".join(scope.exclude) + "\n",
encoding="utf-8",
)
return files_from, exclude_from
def _trim_trailing_slash(s: str) -> str:
return s.rstrip("/") if s.endswith("/") else s
+129
View File
@@ -0,0 +1,129 @@
"""pull + push entry points.
Both subprocess restic against ``rest:<scheme>://<id>:<password>@<host>/<id>/``
where the same password is the HTTP basic credential and the repo
encryption key. cloud-svc provisions one password covering both.
"""
from __future__ import annotations
import sys
import urllib.parse
from pathlib import Path
from . import restic, scope as scopemod
from .cli import Args
from .creds import read_credentials
def pull(args: Args) -> int:
"""Restore latest snapshot's files into pack_folder.
If the repo has no snapshots yet, this is a no-op (first run on this
machine; nothing to restore).
"""
discord_id, password = read_credentials(args.token_file)
binary = restic.resolve_binary(args)
repo = _restic_repo(args.url, discord_id, password)
env = _restic_env(password)
# Check whether any snapshots exist
code, out = restic.run(
binary,
["-r", repo, "snapshots", "--json", "--latest", "1"],
env=env,
)
if code != 0:
print(
f"cloud-sync: failed to list snapshots (restic exit {code})",
file=sys.stderr,
)
return 2
stripped = out.strip()
if stripped in ("", "null", "[]"):
print(
"cloud-sync: no snapshots yet for this user "
"(first run on this machine?); nothing to pull"
)
return 0
scope = scopemod.load(args.pack_folder)
_, exclude_from = scopemod.materialize_for_restic(args.pack_folder, scope)
code, _ = restic.run(
binary,
[
"-r", repo, "restore", "latest",
"--target", str(args.pack_folder),
"--exclude-file", str(exclude_from),
],
env=env,
)
if code != 0:
print(f"cloud-sync: restic restore failed (exit {code})", file=sys.stderr)
return 2
print("cloud-sync: pull ok")
return 0
def push(args: Args) -> int:
"""Snapshot the in-scope files into the user's repo."""
discord_id, password = read_credentials(args.token_file)
binary = restic.resolve_binary(args)
repo = _restic_repo(args.url, discord_id, password)
env = _restic_env(password)
scope = scopemod.load(args.pack_folder)
files_from, exclude_from = scopemod.materialize_for_restic(args.pack_folder, scope)
code, _ = restic.run(
binary,
[
"-r", repo, "backup",
"--files-from", str(files_from),
"--exclude-file", str(exclude_from),
"--host", "cloud-sync",
"--tag", "auto",
],
env=env,
cwd=args.pack_folder,
)
if code != 0:
print(f"cloud-sync: restic backup failed (exit {code})", file=sys.stderr)
return 2
print("cloud-sync: push ok")
return 0
# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------
def _restic_repo(base_url: str, discord_id: str, password: str) -> str:
"""Build rest:<scheme>://<id>:<pw>@<host>/<id>/
URL-embedded basic auth is universally supported by restic; alternative
env vars (RESTIC_REST_USERNAME, RESTIC_REST_PASSWORD) require 0.16+.
"""
raw = base_url.strip()
if raw.startswith("rest:"):
raw = raw[len("rest:"):]
raw = raw.rstrip("/")
scheme_end = raw.find("://")
if scheme_end <= 0:
raise ValueError(
f"--url must include scheme (http:// or https://): {base_url!r}"
)
scheme = raw[: scheme_end + 3]
host_and_path = raw[scheme_end + 3 :]
u = urllib.parse.quote(discord_id, safe="")
p = urllib.parse.quote(password, safe="")
return f"rest:{scheme}{u}:{p}@{host_and_path}/{discord_id}/"
def _restic_env(password: str) -> dict[str, str]:
return {
"RESTIC_PASSWORD": password,
"RESTIC_PROGRESS_FPS": "0",
}