rename: cloud sync -> instance sync; cloud -> Timemachine Network; drop Tk
CI / test (3.10) (push) Successful in 7s
CI / test (3.11) (push) Successful in 7s
CI / test (3.12) (push) Successful in 7s
CI / build-pyz (push) Successful in 4s
CI / release (push) Has been skipped

Product / UI / CLI / docs rebrand. Internal package, repo, and
on-disk dir names stay 'cloud_sync' / 'cloud-sync' / '.cloud-sync/'
to avoid breaking existing installs; a future commit can do the
file-system rename when the cost is worth paying.

User-facing changes:
  CLI prog name:        cloud-sync     -> instance-sync
  CLI description:      cloud-svc URL  -> Timemachine Network endpoint
  Dialog title:         CLOUD SYNC     -> INSTANCE SYNC
  Dialog title:         CLOUD CONFLICT -> INSTANCE CONFLICT
  Dialog title:         CONNECT CLOUD SAVE -> CONNECT TO THE NETWORK
  Card label:           Cloud Save     -> Remote Save
  Skip button:          Skip cloud sync -> Skip instance sync
  Body copy:            'the cloud'    -> 'the Timemachine Network'
  Window titles:        Cloud sync — ... -> Instance sync — ...
  Log prefix:           cloud-sync:    -> instance-sync:
  Error prose:          'cloud-sync token' -> 'instance-sync token'

Backend changes:
  restic --host tag:    cloud-sync     -> instance-sync
  State.host_tag dflt:  cloud-sync     -> instance-sync
  (Existing snapshots with the old tag still pull fine; we use 'latest'.)

Drop tkinter fallback: ui.py now offers Qt OR Headless. tkinter is
unnecessary given we already maintain Qt + headless; one less code
path to keep styled, smaller pyz. make_progress() picks Qt first,
falls through to HeadlessProgress on ImportError with a stderr hint
to 'pip install PySide6'.

README: rebrand title + prose; note repo/dir rename deferred; call
out the PySide6 install step. Conflict/login dialogs are now Qt-only;
without Qt, conflict aborts (defensive) and login tells the user to
paste the token manually.

52 tests green; no test-file label changes needed since they only
exercise internal APIs.
This commit is contained in:
2026-06-05 01:14:02 +02:00
parent f1cb9f4b86
commit b31fdd023a
10 changed files with 99 additions and 232 deletions
+23 -167
View File
@@ -1,25 +1,22 @@
"""Progress UI for cloud-sync operations.
"""Progress UI for instance-sync operations.
Two implementations sharing the ``Progress`` protocol:
- :class:`HeadlessProgress` — no window; prints to stdout/stderr. Used when
``--no-gui`` is set or when tkinter import fails.
- :class:`TkProgressWindow` — tkinter modal window with status text +
indeterminate progress bar + Cancel button. Stdlib only.
``--no-gui`` is set OR when Qt isn't available (the only graphical path
is Qt; there is no tkinter fallback).
- :class:`QtProgressWindow` (in :mod:`cloud_sync.ui_qt`) — Qt modal window
with the Prism-dark Steam-style layout.
The window runs in the main thread; the restic subprocess runs in a
worker thread. The window polls every 100 ms to check whether the worker
finished and whether the user clicked Cancel.
Future option: PySide6 / PyQt6 for a real Qt window matching Prism's
look. Gated behind ``cloud-sync[qt]`` extra in ``pyproject.toml``; not
implemented yet.
The factory :func:`make_progress` picks Qt → Headless. Qt requires
PySide6 or PyQt6 to be importable. Install via
``pip install 'cloud-sync[qt]'`` or directly
``pip install PySide6``.
"""
from __future__ import annotations
import sys
import threading
from typing import Callable, Protocol
@@ -31,16 +28,11 @@ class Progress(Protocol):
def run_with(self, worker: Callable[[], int], title: str) -> int: ...
# ---------------------------------------------------------------------------
# Headless (text-only)
# ---------------------------------------------------------------------------
class HeadlessProgress:
"""No-op progress. Status messages go to stdout, errors to stderr."""
def set_status(self, msg: str) -> None:
print(f"cloud-sync: {msg}", flush=True)
print(f"instance-sync: {msg}", flush=True)
def is_cancelled(self) -> bool:
return False
@@ -50,147 +42,13 @@ class HeadlessProgress:
return worker()
# ---------------------------------------------------------------------------
# Tk window
# ---------------------------------------------------------------------------
class TkProgressWindow:
"""Modal tkinter window with status + indeterminate progress bar.
Usage::
ui = TkProgressWindow()
rc = ui.run_with(lambda: do_the_work(ui), title="Cloud pull")
"""
def __init__(self) -> None:
# Defer imports so headless fall-through doesn't blow up on
# tkinter-less Python builds.
import tkinter as tk
from tkinter import ttk
self._tk = tk
self._ttk = ttk
self._root = tk.Tk()
self._root.title("Cloud sync")
self._root.geometry("440x160")
self._root.resizable(False, False)
self._root.attributes("-topmost", True)
self._root.protocol("WM_DELETE_WINDOW", self._on_close)
frame = ttk.Frame(self._root, padding=20)
frame.pack(fill="both", expand=True)
self._title_var = tk.StringVar(value="Working…")
ttk.Label(
frame,
textvariable=self._title_var,
font=("TkDefaultFont", 11, "bold"),
).pack(anchor="w")
self._status_var = tk.StringVar(value="Starting…")
ttk.Label(frame, textvariable=self._status_var).pack(anchor="w", pady=(4, 8))
self._bar = ttk.Progressbar(frame, mode="indeterminate", length=400)
self._bar.pack(fill="x")
self._bar.start(15)
button_row = ttk.Frame(frame)
button_row.pack(fill="x", pady=(12, 0))
self._cancel_btn = ttk.Button(
button_row, text="Cancel", command=self._on_close
)
self._cancel_btn.pack(side="right")
self._cancelled = False
self._worker_rc: int | None = None
self._worker_exc: BaseException | None = None
self._center_on_screen()
# -- public API ----------------------------------------------------
def set_status(self, msg: str) -> None:
try:
self._status_var.set(msg)
except self._tk.TclError:
# window was destroyed
pass
def is_cancelled(self) -> bool:
return self._cancelled
def run_with(self, worker: Callable[[], int], title: str) -> int:
self._title_var.set(title)
self._status_var.set("Starting…")
def thread_target() -> None:
try:
self._worker_rc = worker()
except BaseException as e: # noqa: BLE001
self._worker_exc = e
t = threading.Thread(target=thread_target, daemon=True)
t.start()
self._root.after(100, self._poll, t)
self._root.mainloop()
if self._worker_exc is not None:
raise self._worker_exc
if self._worker_rc is None:
# User cancelled before worker reported
return 1
return self._worker_rc
# -- internals -----------------------------------------------------
def _poll(self, thread: threading.Thread) -> None:
if not thread.is_alive():
try:
self._bar.stop()
self._root.quit()
self._root.destroy()
except self._tk.TclError:
pass
return
self._root.after(100, self._poll, thread)
def _on_close(self) -> None:
# Mark cancelled; worker checks via is_cancelled. Don't destroy
# window — polling loop will clean up once worker exits.
self._cancelled = True
try:
self._status_var.set("Cancelling…")
self._cancel_btn.configure(state="disabled")
except self._tk.TclError:
pass
def _center_on_screen(self) -> None:
self._root.update_idletasks()
w = self._root.winfo_width()
h = self._root.winfo_height()
sw = self._root.winfo_screenwidth()
sh = self._root.winfo_screenheight()
x = max(0, (sw // 2) - (w // 2))
y = max(0, (sh // 2) - (h // 2))
self._root.geometry(f"+{x}+{y}")
# ---------------------------------------------------------------------------
# Factory
# ---------------------------------------------------------------------------
def make_progress(headless: bool) -> Progress:
"""Pick the best Progress impl for the runtime + flags.
"""Return the best Progress impl for the runtime + flags.
Preference order:
1. Qt (PySide6 or PyQt6) — modern look, matches Prism's aesthetic.
2. tkinter — stdlib fallback; ships with most Python distributions.
3. headless — print to stdout/stderr only.
Override via ``--no-gui`` (forces headless).
Order:
1. Qt window (PySide6 or PyQt6) — preferred when available.
2. HeadlessProgress — fallback when ``--no-gui`` is set or Qt is
missing. Logs to stdout/stderr.
"""
if headless:
return HeadlessProgress()
@@ -198,17 +56,15 @@ def make_progress(headless: bool) -> Progress:
from .ui_qt import QtProgressWindow
return QtProgressWindow()
except ImportError:
pass
except Exception as e: # noqa: BLE001
print(
f"cloud-sync: Qt init failed ({e}); falling back to tkinter",
file=sys.stderr,
)
try:
return TkProgressWindow()
except Exception as e: # noqa: BLE001
print(
f"cloud-sync: tkinter unavailable ({e}); falling back to headless",
"instance-sync: Qt (PySide6/PyQt6) not installed; "
"running headless. Install with: pip install PySide6",
file=sys.stderr,
)
return HeadlessProgress()
except Exception as e: # noqa: BLE001
print(
f"instance-sync: Qt init failed ({e}); falling back to headless",
file=sys.stderr,
)
return HeadlessProgress()