Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6e32a1b18 | ||
|
|
786e3ae9b3 | ||
|
|
ccb89191b5 | ||
|
|
7098fa1c0c |
No files matched your search
@@ -1,5 +1,33 @@
|
||||
# Änderungsprotokoll
|
||||
|
||||
## 1.0.12 – 2026-09-27
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Endlosschleife bei Wiederholungen beseitigt:** Aufgaben in Wiederholungsschleifen werden nach dem Einreihen sauber aus der Retry-Warteliste entfernt und bei reinen Fehlern ohne gesperrte Dateien nicht mehr dauerhaft im 10-Sekunden-Takt neu gestartet.
|
||||
- **Wiederholungs-Backoff und Versuchslimit:** Für gesperrte Dateien greift ein exponentielles Backoff (30s, 60s, 120s bis max. 300s) sowie ein Limit von 5 automatischen Versuchen, um Festplatte, Netzwerk und Prozessor vor unnötigen Dauerläufen zu schützen.
|
||||
- **Flüchtige Cache-Dateien ohne Fehlermeldung:** Dateien, die zwischen Inventarerstellung und Kopiervorgang gelöscht wurden (z. B. dynamische Browser- oder Anwendungs-Caches), werden sauber als übersprungen gewertet und brechen die Sicherung nicht mehr mit `[WinError 2]` ab.
|
||||
- **Kein Einfrieren der Benutzeroberfläche:** Das automatische Weiterscrollen der Protokolltabelle bei vielen gleichzeitigen Meldungen wird gebündelt entprellt, die Abfrage des Windows Restart Managers für gesperrte Ordner wird pro Verzeichnis zwischengespeichert und Fortschrittsmeldungen werden im GUI-Thread gedrosselt.
|
||||
|
||||
## 1.0.11 – 2026-09-27
|
||||
|
||||
### Behoben
|
||||
|
||||
- Der Button „Pausieren“ ist im Leerlauf deaktiviert, damit SyncVito nicht versehentlich ohne aktive Aufgabe in den Pausenzustand versetzt werden kann.
|
||||
- Beim Klick auf „Alle jetzt synchronisieren“ oder „Auswahl synchronisieren“ wird ein eventuell aktiver Pausenzustand automatisch aufgehoben und der Lauf sofort gestartet.
|
||||
- Klickt man auf „Fortsetzen“, wenn zuvor noch keine Aufgabe eingereiht war, startet SyncVito direkt alle aktiven Aufgaben, statt wirkungslos zu bleiben.
|
||||
- Die automatische Hintergrund-Updateprüfung setzt die Fortschrittsleiste im Leerlauf nicht mehr dauerhaft auf 100 % mit „SyncVito ist aktuell“, sondern die Leiste bleibt ordnungsgemäß auf „Bereit“.
|
||||
- Die Tray-Benachrichtigung beim Schließen des Fensters lautet nun unmissverständlich „SyncVito minimiert“ statt „SyncVito läuft weiter“.
|
||||
|
||||
## 1.0.10 – 2026-09-27
|
||||
|
||||
### Neu
|
||||
|
||||
- **Etappen- und Bereichsanzeige:** Beim Synchronisieren wird der aktuelle Hauptordner (z. B. AppData, Dokumente oder definierte Unterauswahlen) zusammen mit der aktuellen Etappe (z. B. `[2/4 AppData]`) im Fortschrittsdock angezeigt, sodass stets ersichtlich ist, in welchem Bereich SyncVito gerade arbeitet und wie viele Ordner noch ausstehen.
|
||||
- **Live-Status in der Aufgabenliste:** Die Aufgaben-Tabelle zeigt laufende Vorgänge live mit aktuellem Prozentsatz (z. B. `🔄 Läuft (45 %)`), wartende Aufgaben mit ihrer Listenposition (`⏳ In Warteschlange (#2)`) sowie pausierte Aufgaben an.
|
||||
- **Pause- und Fortsetzen-Funktion:** Synchronisationsläufe können jederzeit über Buttons im Header, im Progress-Dock sowie im Tray-Menü pausiert und fortgesetzt werden.
|
||||
- **Nahtlose Wiederaufnahme nach Neustart:** Der Pausenzustand und die verbleibende Aufgaben-Warteschlange werden dauerhaft gesichert. Nach einem Neustart des Tools oder des Rechners setzt SyncVito genau an der unterbrochenen Stelle fort; bereits kopierte Dateien werden anhand ihrer Signatur sofort übersprungen.
|
||||
|
||||
## 1.0.9 – 2026-09-24
|
||||
|
||||
### Behoben
|
||||
|
||||
+17
-1
@@ -150,7 +150,23 @@ VSVersionInfo(
|
||||
if ($LASTEXITCODE -ne 0) { throw "Der Release-Commit ist fehlgeschlagen." }
|
||||
}
|
||||
$sourceCommitted = $true
|
||||
git push origin main
|
||||
$pushTarget = "origin"
|
||||
$tokenPath = @(
|
||||
(Join-Path $env:LOCALAPPDATA "SyncVito\developer\gitea-token.dpapi"),
|
||||
(Join-Path $env:LOCALAPPDATA "MusicBrain\developer\gitea-token.dpapi")
|
||||
) | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | Select-Object -First 1
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:GITEA_TOKEN)) {
|
||||
$pushTarget = "https://Davito:$($env:GITEA_TOKEN.Trim())@gitea.davito.synology.me/Davito/SyncVito.git"
|
||||
}
|
||||
elseif ($tokenPath) {
|
||||
try {
|
||||
$sec = (Get-Content $tokenPath -Raw).Trim() | ConvertTo-SecureString
|
||||
$bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec)
|
||||
$t = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
|
||||
$pushTarget = "https://Davito:$t@gitea.davito.synology.me/Davito/SyncVito.git"
|
||||
} catch { }
|
||||
}
|
||||
git push $pushTarget main
|
||||
if ($LASTEXITCODE -ne 0) { throw "Der Quellstand konnte nicht nach Gitea gepusht werden." }
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "syncvito"
|
||||
version = "1.0.9"
|
||||
version = "1.0.12"
|
||||
description = "Windows-Desktopwerkzeug für lokale und NAS-Synchronisation"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
+149
-12
@@ -2,8 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import traceback
|
||||
from collections import deque
|
||||
from contextlib import suppress
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
@@ -40,15 +42,17 @@ class _WorkerSignals(QObject):
|
||||
|
||||
|
||||
class _SyncWorker(QRunnable):
|
||||
def __init__(self, task: SyncTask, engine: SyncEngine) -> None:
|
||||
def __init__(self, task: SyncTask, engine: SyncEngine, pause_event: threading.Event | None = None) -> None:
|
||||
super().__init__()
|
||||
self.task = task
|
||||
self.engine = engine
|
||||
self.pause_event = pause_event
|
||||
self.signals = _WorkerSignals()
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
result = self.engine.synchronize(self.task, self.signals.progress.emit)
|
||||
cancel_check = self.pause_event.is_set if self.pause_event else None
|
||||
result = self.engine.synchronize(self.task, self.signals.progress.emit, cancel_check=cancel_check)
|
||||
except UnavailablePathError as exc:
|
||||
self.signals.failed.emit(self.task.id, str(exc), True)
|
||||
except Exception as exc: # noqa: BLE001 - Worker-Grenze muss UI-Absturz verhindern.
|
||||
@@ -79,9 +83,13 @@ class SyncCoordinator(QObject):
|
||||
self.engine = SyncEngine()
|
||||
self.pool = QThreadPool.globalInstance()
|
||||
self.pool.setMaxThreadCount(max(2, self.pool.maxThreadCount()))
|
||||
self._pause_event = threading.Event()
|
||||
self._paused_task_id: str | None = None
|
||||
self._queue: deque[str] = deque()
|
||||
self._running_task_id: str | None = None
|
||||
self._retry_tasks: dict[str, list[Path]] = {}
|
||||
self._retry_attempts: dict[str, int] = {}
|
||||
self._retry_cooldowns: dict[str, datetime] = {}
|
||||
self._lock_messages: dict[str, str] = {}
|
||||
self._task_states: dict[str, TaskRuntimeState] = {
|
||||
task.id: TaskRuntimeState.ACTIVE if task.enabled else TaskRuntimeState.PAUSED for task in config.tasks
|
||||
@@ -91,6 +99,13 @@ class SyncCoordinator(QObject):
|
||||
self._bridge.changed.connect(self._on_realtime_event)
|
||||
self.monitor = RealtimeMonitor(self._bridge.changed.emit)
|
||||
|
||||
if self.config.settings.pending_queue:
|
||||
for task_id in self.config.settings.pending_queue:
|
||||
if any(t.id == task_id and t.enabled for t in self.config.tasks) and task_id not in self._queue:
|
||||
self._queue.append(task_id)
|
||||
if self.config.settings.paused and self._queue:
|
||||
self._paused_task_id = self._queue[0]
|
||||
|
||||
self._schedule_timer = QTimer(self)
|
||||
self._schedule_timer.setInterval(30_000)
|
||||
self._schedule_timer.timeout.connect(self._check_intervals)
|
||||
@@ -101,6 +116,21 @@ class SyncCoordinator(QObject):
|
||||
self._retry_timer.start()
|
||||
self.rebuild_monitors()
|
||||
|
||||
@property
|
||||
def running_task_id(self) -> str | None:
|
||||
"""ID der aktuell ausgeführten Aufgabe."""
|
||||
return self._running_task_id
|
||||
|
||||
@property
|
||||
def queued_task_ids(self) -> list[str]:
|
||||
"""IDs aller Aufgaben in der aktuellen Warteschlange."""
|
||||
return list(self._queue)
|
||||
|
||||
@property
|
||||
def paused_task_id(self) -> str | None:
|
||||
"""ID der zuletzt unterbrochenen Aufgabe."""
|
||||
return self._paused_task_id
|
||||
|
||||
def task_state(self, task_id: str) -> TaskRuntimeState:
|
||||
"""Liefert den aktuellen Engine-Zustand einer Aufgabe."""
|
||||
return self._task_states.get(task_id, TaskRuntimeState.PAUSED)
|
||||
@@ -114,6 +144,8 @@ class SyncCoordinator(QObject):
|
||||
known_ids = {task.id for task in self.config.tasks}
|
||||
self._task_states = {task_id: state for task_id, state in self._task_states.items() if task_id in known_ids}
|
||||
self._retry_tasks = {task_id: paths for task_id, paths in self._retry_tasks.items() if task_id in known_ids}
|
||||
self._retry_attempts = {task_id: att for task_id, att in self._retry_attempts.items() if task_id in known_ids}
|
||||
self._retry_cooldowns = {task_id: cd for task_id, cd in self._retry_cooldowns.items() if task_id in known_ids}
|
||||
self._lock_messages = {
|
||||
task_id: message for task_id, message in self._lock_messages.items() if task_id in known_ids
|
||||
}
|
||||
@@ -124,6 +156,8 @@ class SyncCoordinator(QObject):
|
||||
self._set_task_state(task.id, desired)
|
||||
if not task.enabled:
|
||||
self._retry_tasks.pop(task.id, None)
|
||||
self._retry_attempts.pop(task.id, None)
|
||||
self._retry_cooldowns.pop(task.id, None)
|
||||
self._lock_messages.pop(task.id, None)
|
||||
enabled_ids = {task.id for task in self.config.tasks if task.enabled}
|
||||
self._queue = deque(task_id for task_id in self._queue if task_id in enabled_ids)
|
||||
@@ -135,20 +169,41 @@ class SyncCoordinator(QObject):
|
||||
"""Zeigt an, ob momentan ein Worker aktiv ist."""
|
||||
return self._running_task_id is not None
|
||||
|
||||
def _save_pending_queue(self) -> None:
|
||||
pending: list[str] = []
|
||||
if self._running_task_id is not None:
|
||||
pending.append(self._running_task_id)
|
||||
for tid in self._queue:
|
||||
if tid not in pending:
|
||||
pending.append(tid)
|
||||
self.config.settings.pending_queue = pending
|
||||
with suppress(Exception):
|
||||
self.config_store.save(self.config)
|
||||
|
||||
def enqueue(self, task_id: str) -> None:
|
||||
"""Stellt eine Aufgabe dedupliziert in die serielle Warteschlange."""
|
||||
if self.config.settings.paused:
|
||||
self.log_created.emit("Synchronisation", "Hinweis", "SyncVito ist pausiert.")
|
||||
return
|
||||
task = self._task(task_id)
|
||||
if task is None or not task.enabled:
|
||||
return
|
||||
self._retry_attempts.pop(task_id, None)
|
||||
self._retry_cooldowns.pop(task_id, None)
|
||||
if self.config.settings.paused:
|
||||
if task_id != self._running_task_id and task_id not in self._queue:
|
||||
self._queue.append(task_id)
|
||||
self._save_pending_queue()
|
||||
self._set_task_state(task_id, TaskRuntimeState.PAUSED)
|
||||
self.log_created.emit("Synchronisation", "Hinweis", "SyncVito ist pausiert. Aufgabe eingereiht.")
|
||||
return
|
||||
if task_id != self._running_task_id and task_id not in self._queue:
|
||||
self._queue.append(task_id)
|
||||
self._save_pending_queue()
|
||||
self._set_task_state(task_id, TaskRuntimeState.ACTIVE)
|
||||
self._start_next()
|
||||
|
||||
def enqueue_all(self) -> None:
|
||||
"""Stellt alle aktiven Aufgaben zur manuellen Ausführung an."""
|
||||
if self.config.settings.paused:
|
||||
self.set_paused(False)
|
||||
for task in self.config.tasks:
|
||||
if task.enabled:
|
||||
self.enqueue(task.id)
|
||||
@@ -156,19 +211,35 @@ class SyncCoordinator(QObject):
|
||||
def set_paused(self, paused: bool) -> None:
|
||||
"""Ändert den globalen Pausenzustand."""
|
||||
self.config.settings.paused = paused
|
||||
self.config_store.save(self.config)
|
||||
if paused:
|
||||
self.log_created.emit("Status", "Hinweis", "Alle automatischen Auslöser sind pausiert.")
|
||||
self._pause_event.set()
|
||||
self._save_pending_queue()
|
||||
self.log_created.emit("Status", "Hinweis", "Synchronisation pausiert.")
|
||||
else:
|
||||
self._pause_event.clear()
|
||||
self._paused_task_id = None
|
||||
self.log_created.emit("Status", "Erfolg", "Synchronisation fortgesetzt.")
|
||||
if not self._queue and self.config.settings.pending_queue:
|
||||
for tid in self.config.settings.pending_queue:
|
||||
if tid not in self._queue and any(t.id == tid and t.enabled for t in self.config.tasks):
|
||||
self._queue.append(tid)
|
||||
if not self._queue and not self._running_task_id:
|
||||
for task in self.config.tasks:
|
||||
if task.enabled:
|
||||
self._queue.append(task.id)
|
||||
self._start_next()
|
||||
with suppress(Exception):
|
||||
self.config_store.save(self.config)
|
||||
self._emit_overall_status()
|
||||
self.refresh_task_states()
|
||||
|
||||
def rebuild_monitors(self) -> None:
|
||||
"""Aktualisiert alle Dateisystemüberwachungen."""
|
||||
errors = self.monitor.rebuild(self.config.tasks)
|
||||
for task_id, message in errors:
|
||||
self._retry_tasks[task_id] = []
|
||||
self._retry_tasks.pop(task_id, None)
|
||||
self._retry_attempts.pop(task_id, None)
|
||||
self._retry_cooldowns.pop(task_id, None)
|
||||
self._set_task_state(task_id, TaskRuntimeState.ERROR)
|
||||
self.log_created.emit("Echtzeit", "Fehler", message)
|
||||
self._emit_overall_status()
|
||||
@@ -176,6 +247,14 @@ class SyncCoordinator(QObject):
|
||||
def stop(self) -> None:
|
||||
"""Stoppt die Dateisystemüberwachung beim Beenden."""
|
||||
self.monitor.stop()
|
||||
if self._running_task_id is not None:
|
||||
self._pause_event.set()
|
||||
self._save_pending_queue()
|
||||
self.config.settings.paused = True
|
||||
with suppress(Exception):
|
||||
self.config_store.save(self.config)
|
||||
if hasattr(self.pool, "waitForDone"):
|
||||
self.pool.waitForDone(1500)
|
||||
|
||||
def _start_next(self) -> None:
|
||||
if self._running_task_id is not None or self.config.settings.paused or not self._queue:
|
||||
@@ -186,11 +265,14 @@ class SyncCoordinator(QObject):
|
||||
self._start_next()
|
||||
return
|
||||
self._running_task_id = task_id
|
||||
self._paused_task_id = None
|
||||
self._save_pending_queue()
|
||||
self.progress_changed.emit(0, f"{task.name}: Synchronisation wird gestartet …")
|
||||
self.idle_changed.emit(False)
|
||||
self._emit_overall_status()
|
||||
self.log_created.emit(task.name, "Start", "Synchronisation gestartet.")
|
||||
worker = _SyncWorker(task, self.engine)
|
||||
self._pause_event.clear()
|
||||
worker = _SyncWorker(task, self.engine, self._pause_event)
|
||||
worker.signals.progress.connect(self.progress_changed)
|
||||
worker.signals.finished.connect(self._on_finished)
|
||||
worker.signals.failed.connect(self._on_failed)
|
||||
@@ -201,8 +283,26 @@ class SyncCoordinator(QObject):
|
||||
if task is None:
|
||||
self._complete_current()
|
||||
return
|
||||
|
||||
if result.paused:
|
||||
self._paused_task_id = task_id
|
||||
if task_id not in self._queue:
|
||||
self._queue.appendleft(task_id)
|
||||
self._save_pending_queue()
|
||||
self._set_task_state(task_id, TaskRuntimeState.PAUSED)
|
||||
self.log_created.emit(task.name, "Pausiert", f"Synchronisation bei {result.copied} Dateien angehalten.")
|
||||
self._running_task_id = None
|
||||
self.progress_changed.emit(0, f"{task.name}: Pausiert – zum Fortsetzen klicken")
|
||||
self.idle_changed.emit(True)
|
||||
self._emit_overall_status()
|
||||
return
|
||||
|
||||
task.last_run = datetime.now().isoformat(timespec="seconds")
|
||||
if task_id in self.config.settings.pending_queue:
|
||||
self.config.settings.pending_queue.remove(task_id)
|
||||
with suppress(Exception):
|
||||
self.config_store.save(self.config)
|
||||
|
||||
if result.blocked:
|
||||
paths = [blocked.path for blocked in result.blocked]
|
||||
self._retry_tasks[task_id] = paths
|
||||
@@ -225,12 +325,17 @@ class SyncCoordinator(QObject):
|
||||
self._emit_lock_summary()
|
||||
if result.errors:
|
||||
# Auch zusätzliche Fehler eines teilweise blockierten Laufs sichtbar halten.
|
||||
self._retry_tasks[task_id] = []
|
||||
if not result.blocked:
|
||||
self._retry_tasks.pop(task_id, None)
|
||||
self._retry_attempts.pop(task_id, None)
|
||||
self._retry_cooldowns.pop(task_id, None)
|
||||
for error in result.errors:
|
||||
self.log_created.emit(task.name, "Fehler", error)
|
||||
self._set_task_state(task_id, TaskRuntimeState.ERROR)
|
||||
elif not result.blocked:
|
||||
self._retry_tasks.pop(task_id, None)
|
||||
self._retry_attempts.pop(task_id, None)
|
||||
self._retry_cooldowns.pop(task_id, None)
|
||||
summary = f"{result.copied} kopiert, {result.deleted} gelöscht, {result.conflicts} Konfliktkopien"
|
||||
self.log_created.emit(task.name, "Erfolg", summary)
|
||||
self._set_task_state(task_id, TaskRuntimeState.ACTIVE)
|
||||
@@ -239,7 +344,9 @@ class SyncCoordinator(QObject):
|
||||
def _on_failed(self, task_id: str, detail: str, inaccessible: bool) -> None:
|
||||
task = self._task(task_id)
|
||||
name = task.name if task else "Aufgabe"
|
||||
self._retry_tasks[task_id] = []
|
||||
self._retry_tasks.pop(task_id, None)
|
||||
self._retry_attempts.pop(task_id, None)
|
||||
self._retry_cooldowns.pop(task_id, None)
|
||||
self._lock_messages.pop(task_id, None)
|
||||
self._emit_lock_summary()
|
||||
self._set_task_state(task_id, TaskRuntimeState.ERROR)
|
||||
@@ -251,6 +358,9 @@ class SyncCoordinator(QObject):
|
||||
self.progress_changed.emit(100, "Abgeschlossen")
|
||||
if not self._queue:
|
||||
self.idle_changed.emit(True)
|
||||
self.config.settings.pending_queue = []
|
||||
with suppress(Exception):
|
||||
self.config_store.save(self.config)
|
||||
self._emit_overall_status()
|
||||
QTimer.singleShot(200, self._start_next)
|
||||
|
||||
@@ -279,9 +389,36 @@ class SyncCoordinator(QObject):
|
||||
def _check_retries(self) -> None:
|
||||
if self.config.settings.paused or self.is_running:
|
||||
return
|
||||
now = datetime.now()
|
||||
ready_tasks: list[str] = []
|
||||
for task_id, paths in list(self._retry_tasks.items()):
|
||||
if paths and all(is_file_locked(path) for path in paths):
|
||||
cooldown_until = self._retry_cooldowns.get(task_id)
|
||||
if cooldown_until and now < cooldown_until:
|
||||
continue
|
||||
attempts = self._retry_attempts.get(task_id, 0)
|
||||
if attempts >= 5:
|
||||
if attempts == 5:
|
||||
task = self._task(task_id)
|
||||
name = task.name if task else task_id
|
||||
self.log_created.emit(
|
||||
name,
|
||||
"Hinweis",
|
||||
"Automatische Wiederholungen vorübergehend ausgesetzt. Bitte sperrende Programme schließen.",
|
||||
)
|
||||
self._retry_attempts[task_id] = attempts + 1
|
||||
continue
|
||||
|
||||
check_paths = paths[:10] if len(paths) > 10 else paths
|
||||
if paths and all(is_file_locked(path) for path in check_paths):
|
||||
continue
|
||||
ready_tasks.append(task_id)
|
||||
|
||||
for task_id in ready_tasks:
|
||||
self._retry_tasks.pop(task_id, None)
|
||||
attempts = self._retry_attempts.get(task_id, 0) + 1
|
||||
self._retry_attempts[task_id] = attempts
|
||||
backoff_seconds = min(300, 30 * (2 ** min(attempts - 1, 4)))
|
||||
self._retry_cooldowns[task_id] = now + timedelta(seconds=backoff_seconds)
|
||||
self.log_created.emit(
|
||||
"Automatischer Versuch", "Hinweis", "Blockierte oder nicht erreichbare Datei wird erneut geprüft."
|
||||
)
|
||||
|
||||
@@ -140,8 +140,8 @@ class MainWindow(QMainWindow):
|
||||
event.ignore()
|
||||
self.hide()
|
||||
self.tray.showMessage(
|
||||
"SyncVito läuft weiter",
|
||||
"Das Fenster wurde in den Infobereich verschoben.",
|
||||
"SyncVito minimiert",
|
||||
"SyncVito bleibt im Infobereich aktiv.",
|
||||
QSystemTrayIcon.MessageIcon.Information,
|
||||
2500,
|
||||
)
|
||||
@@ -235,6 +235,9 @@ class MainWindow(QMainWindow):
|
||||
self.sync_all_button.setProperty("primary", True)
|
||||
self.sync_all_button.clicked.connect(self.coordinator.enqueue_all)
|
||||
layout.addWidget(self.sync_all_button, 0, Qt.AlignmentFlag.AlignVCenter)
|
||||
self.header_pause_button = QPushButton("Pausieren")
|
||||
self.header_pause_button.clicked.connect(self._toggle_pause)
|
||||
layout.addWidget(self.header_pause_button, 0, Qt.AlignmentFlag.AlignVCenter)
|
||||
return header
|
||||
|
||||
def _build_tasks_page(self) -> QWidget:
|
||||
@@ -418,8 +421,14 @@ class MainWindow(QMainWindow):
|
||||
self.progress_bar.setRange(0, 100)
|
||||
self.progress_bar.setValue(0)
|
||||
self.progress_bar.setFormat("%p %")
|
||||
self.dock_pause_button = QPushButton("Pausieren")
|
||||
self.dock_pause_button.setObjectName("DockPauseButton")
|
||||
self.dock_pause_button.setFixedHeight(34)
|
||||
if hasattr(self, "_toggle_pause"):
|
||||
self.dock_pause_button.clicked.connect(self._toggle_pause)
|
||||
layout.addWidget(self.progress_text)
|
||||
layout.addWidget(self.progress_bar)
|
||||
layout.addWidget(self.dock_pause_button)
|
||||
layout.addStretch(1)
|
||||
return dock
|
||||
|
||||
@@ -478,6 +487,11 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def _refresh_tasks(self) -> None:
|
||||
self.task_table.setRowCount(len(self.config.tasks))
|
||||
running_id = self.coordinator.running_task_id
|
||||
queued_ids = self.coordinator.queued_task_ids
|
||||
paused_id = self.coordinator.paused_task_id
|
||||
is_globally_paused = self.config.settings.paused
|
||||
|
||||
for row, task in enumerate(self.config.tasks):
|
||||
name = QTableWidgetItem(task.name)
|
||||
name.setData(Qt.ItemDataRole.UserRole, task.id)
|
||||
@@ -489,9 +503,26 @@ class MainWindow(QMainWindow):
|
||||
TriggerMode.INTERVAL: f"Alle {task.interval_minutes} Min.",
|
||||
}[task.trigger]
|
||||
trigger = QTableWidgetItem(trigger_text)
|
||||
status = QTableWidgetItem(self._runtime_state_text(self.coordinator.task_state(task.id)))
|
||||
|
||||
base_state = self.coordinator.task_state(task.id)
|
||||
if task.id == running_id:
|
||||
val = self.progress_bar.value()
|
||||
status_text = f"🔄 Läuft ({val}%)"
|
||||
tip = f"Wird synchronisiert ({val}%)\n{self.progress_text.toolTip()}"
|
||||
elif is_globally_paused and (task.id == paused_id or task.id in queued_ids):
|
||||
status_text = "⏸ Pausiert"
|
||||
tip = "Aufgabe pausiert – setzt beim Klick auf Fortsetzen fort."
|
||||
elif task.id in queued_ids:
|
||||
pos = queued_ids.index(task.id) + 1
|
||||
status_text = f"⏳ In Warteschlange (#{pos})"
|
||||
tip = f"Steht an Position {pos} der Warteschlange"
|
||||
else:
|
||||
status_text = self._runtime_state_text(base_state)
|
||||
tip = status_text
|
||||
|
||||
status = QTableWidgetItem(status_text)
|
||||
for column, item in enumerate((name, pair, direction, trigger, status)):
|
||||
item.setToolTip(item.text())
|
||||
item.setToolTip(tip if column == 4 else item.text())
|
||||
self.task_table.setItem(row, column, item)
|
||||
|
||||
def _selected_task(self) -> SyncTask | None:
|
||||
@@ -541,6 +572,8 @@ class MainWindow(QMainWindow):
|
||||
if task is None:
|
||||
QMessageBox.information(self, "Keine Auswahl", "Bitte zuerst eine Aufgabe auswählen.")
|
||||
return
|
||||
if self.config.settings.paused:
|
||||
self.coordinator.set_paused(False)
|
||||
self.coordinator.enqueue(task.id)
|
||||
|
||||
def _persist_tasks(self, message: str) -> None:
|
||||
@@ -621,10 +654,18 @@ class MainWindow(QMainWindow):
|
||||
self._mark_update_checked()
|
||||
self._set_update_indicator(None)
|
||||
self._notified_update_version = None
|
||||
self._update_progress(100, "SyncVito ist aktuell")
|
||||
self._append_log("Update", "Erfolg", "Die installierte Version ist aktuell.")
|
||||
if manual:
|
||||
self._update_progress(100, "SyncVito ist aktuell")
|
||||
QMessageBox.information(self, "Keine Updates", "Du verwendest bereits die aktuelle SyncVito-Version.")
|
||||
elif not self.coordinator.is_running:
|
||||
self.progress_text.setText("SyncVito ist aktuell")
|
||||
QTimer.singleShot(3000, self._reset_idle_progress)
|
||||
|
||||
def _reset_idle_progress(self) -> None:
|
||||
if not self.coordinator.is_running and not self.config.settings.paused:
|
||||
self.progress_text.setText("Bereit")
|
||||
self.progress_bar.setValue(0)
|
||||
|
||||
def _on_update_error(self, message: str, manual: bool) -> None:
|
||||
self._update_progress(0, "Updateprüfung fehlgeschlagen")
|
||||
@@ -700,17 +741,57 @@ class MainWindow(QMainWindow):
|
||||
self.tray_sync_action.setEnabled(update_idle and sync_idle)
|
||||
self.update_button.setEnabled(update_idle and sync_idle)
|
||||
self.tray_update_action.setEnabled(update_idle and sync_idle)
|
||||
self._update_pause_action()
|
||||
self._refresh_tasks()
|
||||
|
||||
def _toggle_pause(self) -> None:
|
||||
self.coordinator.set_paused(not self.config.settings.paused)
|
||||
self._update_pause_action()
|
||||
|
||||
def _update_pause_action(self) -> None:
|
||||
self.pause_action.setText("Fortsetzen" if self.config.settings.paused else "Pausieren")
|
||||
is_paused = self.config.settings.paused
|
||||
is_running = self.coordinator.is_running
|
||||
has_queue = bool(self.coordinator.queued_task_ids)
|
||||
|
||||
if is_paused:
|
||||
btn_text = "▶ Fortsetzen"
|
||||
tray_text = "Fortsetzen"
|
||||
btn_enabled = True
|
||||
primary = True
|
||||
elif is_running or has_queue:
|
||||
btn_text = "⏸ Pausieren"
|
||||
tray_text = "Pausieren"
|
||||
btn_enabled = True
|
||||
primary = False
|
||||
else:
|
||||
btn_text = "Pausieren"
|
||||
tray_text = "Pausieren"
|
||||
btn_enabled = False
|
||||
primary = False
|
||||
|
||||
self.pause_action.setText(tray_text)
|
||||
self.pause_action.setEnabled(btn_enabled)
|
||||
for btn in (getattr(self, "header_pause_button", None), getattr(self, "dock_pause_button", None)):
|
||||
if btn is not None:
|
||||
btn.setText(btn_text)
|
||||
btn.setEnabled(btn_enabled)
|
||||
btn.setProperty("primary", primary)
|
||||
btn.style().unpolish(btn)
|
||||
btn.style().polish(btn)
|
||||
|
||||
def _update_progress(self, value: int, detail: str) -> None:
|
||||
self.progress_bar.setValue(value)
|
||||
self.progress_text.setText(detail)
|
||||
running_id = self.coordinator.running_task_id
|
||||
if running_id:
|
||||
for row in range(self.task_table.rowCount()):
|
||||
item = self.task_table.item(row, 0)
|
||||
if item and item.data(Qt.ItemDataRole.UserRole) == running_id:
|
||||
status_item = self.task_table.item(row, 4)
|
||||
if status_item:
|
||||
status_item.setText(f"🔄 Läuft ({value}%)")
|
||||
status_item.setToolTip(detail)
|
||||
break
|
||||
|
||||
def _append_log(self, action: str, level: str, detail: str) -> None:
|
||||
row = self.log_table.rowCount()
|
||||
@@ -722,6 +803,15 @@ class MainWindow(QMainWindow):
|
||||
self.log_table.setItem(row, column, item)
|
||||
if self.log_table.rowCount() > 1000:
|
||||
self.log_table.removeRow(0)
|
||||
self._schedule_log_scroll()
|
||||
|
||||
def _schedule_log_scroll(self) -> None:
|
||||
if not getattr(self, "_log_scroll_pending", False):
|
||||
self._log_scroll_pending = True
|
||||
QTimer.singleShot(50, self._do_scroll_log)
|
||||
|
||||
def _do_scroll_log(self) -> None:
|
||||
self._log_scroll_pending = False
|
||||
self.log_table.scrollToBottom()
|
||||
|
||||
def _set_global_status(self, status: str, text: str) -> None:
|
||||
|
||||
@@ -137,6 +137,7 @@ class AppSettings:
|
||||
font_weight: str = "regular"
|
||||
ui_scale: int = 100
|
||||
paused: bool = False
|
||||
pending_queue: list[str] = field(default_factory=list)
|
||||
check_updates_on_start: bool = True
|
||||
last_update_check: str | None = None
|
||||
|
||||
@@ -148,6 +149,7 @@ class AppSettings:
|
||||
self.font_weight = "regular"
|
||||
if not self.font_family.strip():
|
||||
self.font_family = "Segoe UI Variable"
|
||||
self.pending_queue = [str(item) for item in self.pending_queue if str(item).strip()]
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialisiert die Einstellungen."""
|
||||
@@ -163,6 +165,7 @@ class AppSettings:
|
||||
font_weight=str(data.get("font_weight", "regular")),
|
||||
ui_scale=int(data.get("ui_scale", 100)),
|
||||
paused=bool(data.get("paused", False)),
|
||||
pending_queue=[str(item) for item in data.get("pending_queue", []) if str(item).strip()],
|
||||
check_updates_on_start=bool(data.get("check_updates_on_start", True)),
|
||||
last_update_check=data.get("last_update_check"),
|
||||
)
|
||||
|
||||
@@ -61,6 +61,7 @@ class SyncResult:
|
||||
deleted: int = 0
|
||||
conflicts: int = 0
|
||||
skipped: int = 0
|
||||
paused: bool = False
|
||||
blocked: list[BlockedFile] = field(default_factory=list)
|
||||
errors: list[str] = field(default_factory=list)
|
||||
inaccessible: bool = False
|
||||
@@ -68,7 +69,7 @@ class SyncResult:
|
||||
@property
|
||||
def successful(self) -> bool:
|
||||
"""Zeigt an, ob der Lauf ohne Fehler und Sperren abgeschlossen wurde."""
|
||||
return not self.errors and not self.blocked and not self.inaccessible
|
||||
return not self.errors and not self.blocked and not self.inaccessible and not self.paused
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -86,16 +87,31 @@ class SyncEngine:
|
||||
def __init__(self, state_store: StateStore | None = None) -> None:
|
||||
self.state_store = state_store or StateStore()
|
||||
|
||||
def synchronize(self, task: SyncTask, progress: ProgressCallback | None = None) -> SyncResult:
|
||||
def synchronize(
|
||||
self,
|
||||
task: SyncTask,
|
||||
progress: ProgressCallback | None = None,
|
||||
cancel_check: Callable[[], bool] | None = None,
|
||||
) -> SyncResult:
|
||||
"""Führt eine Aufgabe aus und isoliert Fehler auf einzelne Dateien."""
|
||||
task.validate()
|
||||
source_root = Path(task.source)
|
||||
target_root = Path(task.target)
|
||||
if cancel_check and cancel_check():
|
||||
return SyncResult(paused=True)
|
||||
if progress:
|
||||
progress(0, f"{task.name}: Ordner werden geprüft …")
|
||||
self._validate_roots(source_root, target_root)
|
||||
source_files, source_dirs = self._inventory(source_root, task.selected_paths, progress, "Quelle")
|
||||
target_files, target_dirs = self._inventory(target_root, task.selected_paths, progress, "Ziel")
|
||||
source_files, source_dirs = self._inventory(
|
||||
source_root, task.selected_paths, progress, "Quelle", cancel_check=cancel_check
|
||||
)
|
||||
if cancel_check and cancel_check():
|
||||
return SyncResult(paused=True)
|
||||
target_files, target_dirs = self._inventory(
|
||||
target_root, task.selected_paths, progress, "Ziel", cancel_check=cancel_check
|
||||
)
|
||||
if cancel_check and cancel_check():
|
||||
return SyncResult(paused=True)
|
||||
if progress:
|
||||
progress(0, f"{task.name}: Änderungen werden verglichen …")
|
||||
previous = self.state_store.get(task.id)
|
||||
@@ -118,12 +134,34 @@ class SyncEngine:
|
||||
|
||||
result = SyncResult()
|
||||
total = max(1, len(operations))
|
||||
stages: list[str] = []
|
||||
for op in operations:
|
||||
is_dir = op.kind in {"Ordner anlegen", "Ordner löschen"}
|
||||
stage_name = _operation_stage(op.relative, task.selected_paths, is_dir=is_dir)
|
||||
if stage_name not in stages:
|
||||
stages.append(stage_name)
|
||||
|
||||
known_locking_processes: dict[Path, tuple[LockingProcess, ...]] = {}
|
||||
rm_query_count = 0
|
||||
last_progress_time = 0.0
|
||||
current_stage = ""
|
||||
|
||||
if progress:
|
||||
progress(0, "Änderungen werden vorbereitet …")
|
||||
for index, operation in enumerate(operations, start=1):
|
||||
is_dir = operation.kind in {"Ordner anlegen", "Ordner löschen"}
|
||||
stage = _operation_stage(operation.relative, task.selected_paths, is_dir=is_dir)
|
||||
if cancel_check and cancel_check():
|
||||
result.paused = True
|
||||
if progress:
|
||||
progress(round((index - 1) / total * 100), f"Pausiert bei: {stage} – {operation.relative}")
|
||||
break
|
||||
try:
|
||||
self._execute(operation, result)
|
||||
except (OSError, shutil.Error) as exc:
|
||||
if isinstance(exc, FileNotFoundError) and operation.source and not operation.source.exists():
|
||||
result.skipped += 1
|
||||
continue
|
||||
failed_path = (
|
||||
Path(exc.filename2 or exc.filename)
|
||||
if isinstance(exc, OSError) and (exc.filename2 or exc.filename)
|
||||
@@ -131,17 +169,36 @@ class SyncEngine:
|
||||
)
|
||||
blocked_path = _blocked_path(operation, exc)
|
||||
if blocked_path is not None:
|
||||
processes = tuple(find_locking_processes(blocked_path))
|
||||
parent_dir = blocked_path.parent
|
||||
if parent_dir in known_locking_processes:
|
||||
processes = known_locking_processes[parent_dir]
|
||||
elif rm_query_count < 10:
|
||||
discovered = tuple(find_locking_processes(blocked_path))
|
||||
rm_query_count += 1
|
||||
if discovered:
|
||||
known_locking_processes[parent_dir] = discovered
|
||||
processes = discovered
|
||||
else:
|
||||
processes = next(iter(known_locking_processes.values()), ())
|
||||
result.blocked.append(BlockedFile(blocked_path, processes))
|
||||
elif isinstance(exc, PermissionError):
|
||||
result.errors.append(f"Zugriff verweigert: {failed_path} ({exc})")
|
||||
else:
|
||||
result.errors.append(f"{failed_path}: {exc}")
|
||||
if progress:
|
||||
now = monotonic()
|
||||
is_last = index == total
|
||||
stage_changed = stage != current_stage
|
||||
if progress and (is_last or stage_changed or now - last_progress_time >= 0.05):
|
||||
last_progress_time = now
|
||||
current_stage = stage
|
||||
if len(stages) > 1:
|
||||
stage_idx = stages.index(stage) + 1
|
||||
detail = f"[{stage_idx}/{len(stages)} {stage}] {operation.kind}: {operation.relative}"
|
||||
else:
|
||||
detail = f"{operation.kind}: {operation.relative}"
|
||||
progress(round(index / total * 100), detail)
|
||||
|
||||
if not operations and progress:
|
||||
if not operations and progress and not result.paused:
|
||||
progress(100, "Keine Änderungen gefunden")
|
||||
|
||||
if task.direction is SyncDirection.BIDIRECTIONAL and result.successful:
|
||||
@@ -168,6 +225,7 @@ class SyncEngine:
|
||||
selected_paths: list[str],
|
||||
progress: ProgressCallback | None = None,
|
||||
label: str = "Ordner",
|
||||
cancel_check: Callable[[], bool] | None = None,
|
||||
) -> tuple[dict[str, FileRecord], set[str]]:
|
||||
files: dict[str, FileRecord] = {}
|
||||
directories: set[str] = set()
|
||||
@@ -177,6 +235,8 @@ class SyncEngine:
|
||||
progress(0, f"{label} wird eingelesen: {root}")
|
||||
try:
|
||||
for current, dir_names, file_names in os.walk(root):
|
||||
if cancel_check and cancel_check():
|
||||
break
|
||||
current_path = Path(current)
|
||||
relative_dir = current_path.relative_to(root)
|
||||
dir_names[:] = [
|
||||
@@ -307,6 +367,9 @@ class SyncEngine:
|
||||
return
|
||||
if operation.source is None:
|
||||
return
|
||||
if not operation.source.exists():
|
||||
result.skipped += 1
|
||||
return
|
||||
operation.target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if operation.conflict_copy and operation.target.exists():
|
||||
conflict_path = _conflict_path(operation.target)
|
||||
@@ -384,3 +447,17 @@ def _is_selected(parts: tuple[str, ...], selections: tuple[tuple[str, ...], ...]
|
||||
if not selections:
|
||||
return True
|
||||
return any(parts[: len(selection)] == selection or selection[: len(parts)] == parts for selection in selections)
|
||||
|
||||
|
||||
def _operation_stage(relative: str, selected_paths: list[str], is_dir: bool = False) -> str:
|
||||
"""Ermittelt den relevanten Hauptordner oder die gewählte Unterauswahl."""
|
||||
parts = Path(relative).parts
|
||||
if not parts:
|
||||
return "Hauptordner"
|
||||
for selected in selected_paths:
|
||||
selected_parts = Path(selected).parts
|
||||
if parts[: len(selected_parts)] == selected_parts:
|
||||
return "/".join(selected_parts)
|
||||
if len(parts) > 1:
|
||||
return parts[0]
|
||||
return parts[0] if is_dir else "Hauptordner"
|
||||
@@ -73,6 +73,30 @@ def test_retry_queues_all_ready_tasks(coordinator: SyncCoordinator, monkeypatch:
|
||||
monkeypatch.setattr(coordinator, "enqueue", queued.append)
|
||||
coordinator._check_retries()
|
||||
assert queued == ["first", "second"]
|
||||
assert "first" not in coordinator._retry_tasks
|
||||
assert "second" not in coordinator._retry_tasks
|
||||
|
||||
|
||||
def test_task_with_only_errors_is_not_queued_for_auto_retry(
|
||||
coordinator: SyncCoordinator, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
task = coordinator.config.tasks[0]
|
||||
coordinator._on_finished(task.id, SyncResult(errors=["Zugriff verweigert"]))
|
||||
assert task.id not in coordinator._retry_tasks
|
||||
queued = []
|
||||
monkeypatch.setattr(coordinator, "enqueue", queued.append)
|
||||
coordinator._check_retries()
|
||||
assert queued == []
|
||||
|
||||
|
||||
def test_retry_max_attempts_cap(coordinator: SyncCoordinator, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
task = coordinator.config.tasks[0]
|
||||
coordinator._retry_tasks[task.id] = []
|
||||
coordinator._retry_attempts[task.id] = 5
|
||||
queued = []
|
||||
monkeypatch.setattr(coordinator, "enqueue", queued.append)
|
||||
coordinator._check_retries()
|
||||
assert queued == []
|
||||
|
||||
|
||||
def test_start_resets_stale_progress_before_worker_starts(
|
||||
@@ -93,3 +117,61 @@ def test_start_resets_stale_progress_before_worker_starts(
|
||||
coordinator.enqueue(coordinator.config.tasks[0].id)
|
||||
assert len(workers) == 1
|
||||
assert coordinator.current_overall_status() == ("active", "Synchronisierung läuft")
|
||||
|
||||
|
||||
def test_pause_and_resume_preserves_queue_and_state(coordinator: SyncCoordinator) -> None:
|
||||
task1 = coordinator.config.tasks[0]
|
||||
task2 = SyncTask(name="Documents", source="c:/src2", target="c:/dst2")
|
||||
coordinator.config.tasks.append(task2)
|
||||
|
||||
# Global pausieren
|
||||
coordinator.set_paused(True)
|
||||
assert coordinator.config.settings.paused
|
||||
assert coordinator.current_overall_status() == ("paused", "Pausiert")
|
||||
|
||||
# Aufgaben in Warteschlange einreihen
|
||||
coordinator.enqueue(task1.id)
|
||||
coordinator.enqueue(task2.id)
|
||||
|
||||
assert coordinator.queued_task_ids == [task1.id, task2.id]
|
||||
assert coordinator.config.settings.pending_queue == [task1.id, task2.id]
|
||||
|
||||
# Fortsetzen
|
||||
workers = []
|
||||
from types import SimpleNamespace
|
||||
|
||||
coordinator.pool = SimpleNamespace(start=workers.append)
|
||||
coordinator.set_paused(False)
|
||||
|
||||
assert not coordinator.config.settings.paused
|
||||
assert len(workers) == 1
|
||||
assert coordinator.running_task_id == task1.id
|
||||
assert coordinator.queued_task_ids == [task2.id]
|
||||
|
||||
|
||||
def test_stop_persists_running_task_and_queue_for_restart(coordinator: SyncCoordinator) -> None:
|
||||
task1 = coordinator.config.tasks[0]
|
||||
task2 = SyncTask(name="Documents", source="c:/src2", target="c:/dst2")
|
||||
coordinator.config.tasks.append(task2)
|
||||
|
||||
coordinator._running_task_id = task1.id
|
||||
coordinator._queue.append(task2.id)
|
||||
|
||||
coordinator.stop()
|
||||
|
||||
assert coordinator.config.settings.paused is True
|
||||
assert coordinator.config.settings.pending_queue == [task1.id, task2.id]
|
||||
|
||||
|
||||
def test_enqueue_all_unpauses_and_runs(coordinator: SyncCoordinator) -> None:
|
||||
from types import SimpleNamespace
|
||||
|
||||
coordinator.set_paused(True)
|
||||
workers = []
|
||||
coordinator.pool = SimpleNamespace(start=workers.append)
|
||||
|
||||
coordinator.enqueue_all()
|
||||
|
||||
assert not coordinator.config.settings.paused
|
||||
assert len(workers) == 1
|
||||
assert coordinator.running_task_id == coordinator.config.tasks[0].id
|
||||
@@ -223,3 +223,73 @@ def test_bidirectional_mirrors_known_deletion(tmp_path: Path) -> None:
|
||||
|
||||
assert result.successful
|
||||
assert not (source / "entfernen.txt").exists()
|
||||
|
||||
|
||||
def test_stage_progress_reporting_for_multiple_folders(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
target = tmp_path / "target"
|
||||
(source / "AppData").mkdir(parents=True)
|
||||
(source / "Documents").mkdir(parents=True)
|
||||
(source / "AppData" / "config.json").write_text("{}", encoding="utf-8")
|
||||
(source / "Documents" / "doc.txt").write_text("text", encoding="utf-8")
|
||||
task = SyncTask(name="Backup", source=str(source), target=str(target))
|
||||
|
||||
messages: list[tuple[int, str]] = []
|
||||
result = _engine(tmp_path).synchronize(task, progress=lambda v, d: messages.append((v, d)))
|
||||
|
||||
assert result.successful
|
||||
assert any("[1/2 AppData]" in detail for _, detail in messages)
|
||||
assert any("[2/2 Documents]" in detail for _, detail in messages)
|
||||
|
||||
|
||||
def test_pause_and_resume_continues_where_interrupted(tmp_path: Path) -> None:
|
||||
source = tmp_path / "source"
|
||||
target = tmp_path / "target"
|
||||
source.mkdir()
|
||||
(source / "a.txt").write_text("a", encoding="utf-8")
|
||||
(source / "b.txt").write_text("b", encoding="utf-8")
|
||||
(source / "c.txt").write_text("c", encoding="utf-8")
|
||||
task = SyncTask(name="ResumeTest", source=str(source), target=str(target))
|
||||
engine = _engine(tmp_path)
|
||||
|
||||
# 1. Unterbrechung nach der ersten Datei
|
||||
def cancel_after_one() -> bool:
|
||||
return (target / "a.txt").exists()
|
||||
|
||||
result_paused = engine.synchronize(task, cancel_check=cancel_after_one)
|
||||
assert result_paused.paused
|
||||
assert not result_paused.successful
|
||||
assert (target / "a.txt").exists()
|
||||
assert not (target / "c.txt").exists()
|
||||
|
||||
# 2. Fortsetzen: Bereits kopierte Datei a.txt wird übersprungen, b und c werden kopiert
|
||||
result_resumed = engine.synchronize(task)
|
||||
assert result_resumed.successful
|
||||
assert result_resumed.copied == 2
|
||||
assert (target / "a.txt").exists()
|
||||
assert (target / "b.txt").exists()
|
||||
assert (target / "c.txt").exists()
|
||||
|
||||
|
||||
def test_concurrently_deleted_source_file_is_skipped(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
source = tmp_path / "source"
|
||||
target = tmp_path / "target"
|
||||
source.mkdir()
|
||||
target.mkdir()
|
||||
(source / "ephemeral.txt").write_text("wird gelöscht")
|
||||
task = SyncTask(name="Transient", source=str(source), target=str(target))
|
||||
engine = _engine(tmp_path)
|
||||
|
||||
# Simuliere, dass die Datei kurz vor der Ausführung gelöscht wurde
|
||||
def delete_before_execute(operation, result):
|
||||
if operation.source and operation.source.name == "ephemeral.txt":
|
||||
operation.source.unlink(missing_ok=True)
|
||||
return SyncEngine._execute(operation, result)
|
||||
|
||||
monkeypatch.setattr(engine, "_execute", delete_before_execute)
|
||||
result = engine.synchronize(task)
|
||||
|
||||
assert result.successful
|
||||
assert result.skipped == 1
|
||||
assert not result.errors
|
||||
assert not (target / "ephemeral.txt").exists()
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.0.9
|
||||
1.0.12
|
||||
Reference in new issue
Block a user