pivot to Python: replace Kotlin/JVM with stdlib zipapp
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:
@@ -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__"]
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
Reference in New Issue
Block a user