Aggiunge tray app e backup manuale

This commit is contained in:
2026-06-30 20:54:32 +02:00
parent 4dca5fbf95
commit 8f624f5b97
7 changed files with 444 additions and 0 deletions

View File

@@ -37,6 +37,15 @@ python -m pip install -e .
python -m bakrest.watchdog_runner
```
## Avvio tray app per test
```powershell
python -m pip install -e .
python -m bakrest.tray_app
```
La tray app legge lo stato prodotto dal servizio e offre il comando `Backup e spegni`. Se il backup fallisce, lo spegnimento non viene eseguito.
## Installazione servizio Windows
Da PowerShell avviato come amministratore:

View File

@@ -80,6 +80,8 @@ Il backup deve essere eseguito dalla tray app quando l'utente seleziona `Backup
Il motore consigliato e' il vero `rsync`, eseguito da Python tramite `subprocess`. Python deve fare da orchestratore, non da reimplementazione di `rsync`.
Per la prima versione il programma non deve bloccare, intercettare o ritardare lo spegnimento normale del PC tramite menu Start o altri comandi Windows standard. Il comando `Backup e spegni` e' un percorso esplicito e consigliato, ma l'utente deve poter spegnere il computer con le vie usuali. La gestione del caso in cui l'utente dimentichi il backup prima dello spegnimento verra' progettata in una fase successiva.
Flusso previsto:
1. L'utente clicca la tray icon.
@@ -93,6 +95,12 @@ Flusso previsto:
9. Se il backup e' riuscito, la tray app spegne il computer.
10. Se il backup fallisce, il computer non viene spento.
Se il server di backup non e' raggiungibile, la tray app deve mostrare una finestra di messaggio con testo simile a:
```text
Server non raggiungibile. E' spento?
```
## Registro dei cambiamenti
Il servizio watchdog deve registrare i cambiamenti in modo persistente, cosi' da non perdere eventi in caso di riavvio o crash.

View File

@@ -5,6 +5,8 @@ description = "Windows watchdog service and tray backup utility."
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"pillow>=10.0.0",
"pystray>=0.19.5",
"watchdog>=4.0.0",
"pywin32>=306; platform_system == 'Windows'",
]

229
src/bakrest/backup.py Normal file
View File

@@ -0,0 +1,229 @@
from __future__ import annotations
import logging
import os
import subprocess
import tempfile
import time
from dataclasses import dataclass
from pathlib import Path
from .config import AppConfig
from .registry import ChangeEvent, ChangeRegistry
from .server_check import check_server
@dataclass(frozen=True)
class BackupResult:
success: bool
message: str
copied_count: int = 0
skipped_count: int = 0
error_code: str | None = None
def run_backup(config: AppConfig, logger: logging.Logger) -> BackupResult:
status = check_server(config.backup)
if not status.reachable:
message = f"Server backup non raggiungibile: {status.message}"
logger.error(message)
return BackupResult(False, message, error_code="server_unreachable")
registry = ChangeRegistry(config.storage.change_registry)
pending = registry.list_pending()
if not pending:
logger.info("No pending files to back up")
return BackupResult(True, "Nessun file in attesa", copied_count=0)
existing, skipped = _split_existing_files(pending)
if skipped:
logger.info("Skipped %s non-existing files", len(skipped))
registry.mark_status([event.path for event in skipped], "ignored")
stable, unstable = _split_stable_files(existing, config)
if unstable:
logger.warning("Skipped %s unstable files", len(unstable))
if not stable:
return BackupResult(
False,
"Nessun file stabile da copiare",
copied_count=0,
skipped_count=len(skipped) + len(unstable),
)
files_from_path: str | None = None
try:
command = _build_rsync_command(config, stable)
files_from_path = _extract_files_from_path(command)
logger.info("Running rsync command: %s", _redact_command(command))
completed = subprocess.run(command, capture_output=True, text=True, check=False)
except OSError as exc:
message = f"Errore avvio rsync: {exc}"
logger.exception(message)
return BackupResult(
False,
message,
skipped_count=len(skipped) + len(unstable),
error_code="rsync_start_failed",
)
finally:
if files_from_path:
_delete_temp_file(files_from_path, logger)
if completed.stdout:
logger.info("rsync stdout: %s", completed.stdout.strip())
if completed.stderr:
logger.warning("rsync stderr: %s", completed.stderr.strip())
if completed.returncode != 0:
message = f"rsync fallito con codice {completed.returncode}"
logger.error(message)
return BackupResult(
False,
message,
skipped_count=len(skipped) + len(unstable),
error_code="rsync_failed",
)
registry.mark_status([event.path for event in stable], "copied")
message = f"Backup completato: {len(stable)} file copiati"
logger.info(message)
return BackupResult(True, message, copied_count=len(stable), skipped_count=len(skipped) + len(unstable))
def shutdown_after_backup(config: AppConfig, logger: logging.Logger) -> bool:
if not config.backup.shutdown_on_success:
logger.info("Shutdown disabled by configuration")
return False
logger.info("Executing shutdown command: %s", config.backup.shutdown_command)
subprocess.run(config.backup.shutdown_command, shell=True, check=False)
return True
def _split_existing_files(events: list[ChangeEvent]) -> tuple[list[ChangeEvent], list[ChangeEvent]]:
existing: list[ChangeEvent] = []
skipped: list[ChangeEvent] = []
for event in events:
if event.event_type == "deleted":
skipped.append(event)
elif Path(event.path).is_file():
existing.append(event)
else:
skipped.append(event)
return existing, skipped
def _split_stable_files(events: list[ChangeEvent], config: AppConfig) -> tuple[list[ChangeEvent], list[ChangeEvent]]:
stable: list[ChangeEvent] = []
unstable: list[ChangeEvent] = []
for event in events:
if _is_file_stable(Path(event.path), config):
stable.append(event)
else:
unstable.append(event)
return stable, unstable
def _is_file_stable(path: Path, config: AppConfig) -> bool:
try:
stat = path.stat()
except OSError:
return False
if time.time() - stat.st_mtime < config.stability.min_age_seconds:
return False
previous = (stat.st_size, stat.st_mtime_ns)
for _ in range(config.stability.unchanged_checks_required):
time.sleep(config.stability.unchanged_check_interval_seconds)
try:
current_stat = path.stat()
except OSError:
return False
current = (current_stat.st_size, current_stat.st_mtime_ns)
if current != previous:
return False
previous = current
return True
def _build_rsync_command(config: AppConfig, events: list[ChangeEvent]) -> list[str]:
roots = _common_roots(config, events)
if len(roots) == 1:
source_root = roots[0]
files_from_entries = [_relative_posix(Path(event.path), source_root) for event in events]
else:
source_root = Path(Path(event.path).anchor or os.sep)
files_from_entries = [_relative_posix(Path(event.path), source_root) for event in events]
files_from_path = _write_files_from(files_from_entries)
return [
config.backup.rsync_path,
"-av",
"--relative",
f"--files-from={files_from_path}",
_source_arg(source_root),
config.backup.remote_destination,
]
def _common_roots(config: AppConfig, events: list[ChangeEvent]) -> list[Path]:
roots: list[Path] = []
for event in events:
path = Path(event.path)
for include_dir in config.watch.include_dirs:
try:
path.relative_to(include_dir)
if include_dir not in roots:
roots.append(include_dir)
break
except ValueError:
continue
return roots
def _write_files_from(entries: list[str]) -> str:
handle = tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
newline="\n",
prefix="bakrest-files-",
suffix=".txt",
delete=False,
)
with handle:
for entry in entries:
handle.write(f"{entry}\n")
return handle.name
def _extract_files_from_path(command: list[str]) -> str | None:
prefix = "--files-from="
for item in command:
if item.startswith(prefix):
return item[len(prefix) :]
return None
def _delete_temp_file(path: str, logger: logging.Logger) -> None:
try:
Path(path).unlink(missing_ok=True)
except OSError:
logger.warning("Could not delete temporary rsync file list: %s", path)
def _relative_posix(path: Path, root: Path) -> str:
return path.relative_to(root).as_posix()
def _source_arg(path: Path) -> str:
value = str(path)
if not value.endswith(("\\", "/")):
value += "\\"
return value
def _redact_command(command: list[str]) -> str:
return " ".join(command)

View File

@@ -46,6 +46,37 @@ class ChangeRegistry:
).fetchone()
return int(row[0])
def list_pending(self) -> list[ChangeEvent]:
with closing(self._connect()) as connection:
rows = connection.execute(
"""
SELECT path, event_type, old_path, size
FROM changes
WHERE status = 'pending'
ORDER BY updated_at ASC
"""
).fetchall()
return [
ChangeEvent(path=row[0], event_type=row[1], old_path=row[2], size=row[3])
for row in rows
]
def mark_status(self, paths: list[str], status: str) -> None:
if not paths:
return
timestamp = datetime.now(timezone.utc).isoformat()
placeholders = ", ".join("?" for _ in paths)
with closing(self._connect()) as connection:
connection.execute(
f"""
UPDATE changes
SET status = ?, updated_at = ?
WHERE path IN ({placeholders})
""",
[status, timestamp, *paths],
)
connection.commit()
def _initialize(self) -> None:
with closing(self._connect()) as connection:
connection.execute(

113
src/bakrest/tray_app.py Normal file
View File

@@ -0,0 +1,113 @@
from __future__ import annotations
import json
import logging
import threading
import tkinter as tk
from tkinter import messagebox
from pathlib import Path
from PIL import Image, ImageDraw
import pystray
from .backup import run_backup, shutdown_after_backup
from .config import AppConfig, load_config
from .logging_setup import configure_logging
from .registry import ChangeRegistry
class BakRestTrayApp:
def __init__(self, config: AppConfig, logger: logging.Logger) -> None:
self.config = config
self.logger = logger
self.registry = ChangeRegistry(config.storage.change_registry)
self.icon = pystray.Icon(
"BakRestTray",
_create_icon_image(),
"Bak&Rest",
self._build_menu(),
)
def run(self) -> None:
self.logger.info("BakRestTray started")
self.icon.run()
def _build_menu(self) -> pystray.Menu:
return pystray.Menu(
pystray.MenuItem(lambda item: self._status_text(), None, enabled=False),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Backup e spegni", self._backup_and_shutdown),
pystray.MenuItem("Aggiorna stato", self._refresh),
pystray.MenuItem("Esci", self._quit),
)
def _status_text(self) -> str:
pending = self.registry.pending_count()
server = self._read_server_status()
return f"Server: {server} | File in attesa: {pending}"
def _read_server_status(self) -> str:
path = self.config.storage.status_file
if not path.exists():
return "sconosciuto"
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return "errore stato"
return "raggiungibile" if data.get("reachable") else "non raggiungibile"
def _backup_and_shutdown(self, icon: pystray.Icon, item: pystray.MenuItem) -> None:
threading.Thread(target=self._backup_and_shutdown_worker, daemon=True).start()
def _backup_and_shutdown_worker(self) -> None:
self.logger.info("User requested Backup e spegni")
self.icon.title = "Bak&Rest - backup in corso"
result = run_backup(self.config, self.logger)
if result.success:
self.icon.title = "Bak&Rest - backup completato"
shutdown_after_backup(self.config, self.logger)
else:
self.icon.title = "Bak&Rest - backup fallito"
if result.error_code == "server_unreachable":
_show_error_message("Bak&Rest", "Server non raggiungibile. E' spento?")
else:
_show_error_message("Bak&Rest", result.message)
self.icon.update_menu()
def _refresh(self, icon: pystray.Icon, item: pystray.MenuItem) -> None:
self.icon.update_menu()
def _quit(self, icon: pystray.Icon, item: pystray.MenuItem) -> None:
self.logger.info("BakRestTray stopped")
self.icon.stop()
def _create_icon_image() -> Image.Image:
image = Image.new("RGBA", (64, 64), (26, 38, 52, 255))
draw = ImageDraw.Draw(image)
draw.rounded_rectangle((10, 14, 54, 50), radius=8, fill=(77, 163, 255, 255))
draw.rectangle((18, 24, 46, 44), fill=(245, 248, 252, 255))
draw.line((22, 34, 32, 42, 46, 26), fill=(26, 38, 52, 255), width=5)
return image
def _show_error_message(title: str, message: str) -> None:
root = tk.Tk()
root.withdraw()
root.attributes("-topmost", True)
try:
messagebox.showerror(title, message, parent=root)
finally:
root.destroy()
def main() -> int:
config = load_config()
logger = configure_logging(config.storage.logs_dir, "tray")
app = BakRestTrayApp(config, logger)
app.run()
return 0
if __name__ == "__main__":
raise SystemExit(main())

52
tests/test_backup.py Normal file
View File

@@ -0,0 +1,52 @@
from __future__ import annotations
from pathlib import Path
from bakrest.backup import _build_rsync_command, _split_existing_files
from bakrest.config import parse_config
from bakrest.registry import ChangeEvent
def test_split_existing_files_ignores_deleted(tmp_path: Path) -> None:
existing = tmp_path / "image.jpg"
existing.write_text("ok", encoding="utf-8")
events = [
ChangeEvent(path=str(existing), event_type="modified"),
ChangeEvent(path=str(tmp_path / "missing.jpg"), event_type="deleted"),
]
present, skipped = _split_existing_files(events)
assert [event.path for event in present] == [str(existing)]
assert [event.path for event in skipped] == [str(tmp_path / "missing.jpg")]
def test_build_rsync_command_uses_files_from(tmp_path: Path) -> None:
source = tmp_path / "source"
source.mkdir()
image = source / "image.jpg"
image.write_text("ok", encoding="utf-8")
config = parse_config(
{
"watch": {
"include_dirs": [str(source)],
"include_extensions": [".jpg"],
"exclude_dirs": [],
"exclude_patterns": [],
},
"backup": {
"rsync_path": "rsync",
"remote_destination": "user@host:/backup/",
"server_check": {"type": "tcp", "port": 22, "interval_seconds": 300, "timeout_seconds": 1},
},
},
tmp_path / "config.toml",
)
command = _build_rsync_command(config, [ChangeEvent(path=str(image), event_type="modified")])
assert command[0] == "rsync"
assert "-av" in command
assert "--relative" in command
assert any(part.startswith("--files-from=") for part in command)
assert command[-1] == "user@host:/backup/"