Release SyncVito 1.0.9

This commit is contained in:
Davito committed 2026-09-24 16:46:22 +02:00
1 parent bb5bfda4fe
commit 4efc1efa18
8 files changed
+136 -9

No files matched your search

+9
View File
@@ -1,5 +1,14 @@
# Änderungsprotokoll # Änderungsprotokoll
## 1.0.9 – 2026-09-24
### Behoben
- Beim Start einer Synchronisation wird die Fortschrittsanzeige sofort zurückgesetzt. Bereits beim Einlesen von Quelle und Ziel erscheinen der aktuelle Ordner und die Anzahl gefundener Dateien; eine vorherige Updateanzeige bleibt nicht mehr unverändert stehen.
- Der Gesamtstatus kennzeichnet einen laufenden Vorgang eindeutig mit „Synchronisierung läuft“.
- Windows-Verzeichnisverknüpfungen (Junctions) werden beim Einlesen übersprungen, damit insbesondere AppData nicht mehrfach oder zyklisch durchsucht wird.
- Bei einer Unterauswahl werden nicht ausgewählte Verzeichniszweige nicht mehr vollständig durchsucht.
## 1.0.8 – 2026-09-24 ## 1.0.8 – 2026-09-24
### Behoben ### Behoben
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "syncvito" name = "syncvito"
version = "1.0.8" version = "1.0.9"
description = "Windows-Desktopwerkzeug für lokale und NAS-Synchronisation" description = "Windows-Desktopwerkzeug für lokale und NAS-Synchronisation"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
+3 -2
View File
@@ -25,12 +25,12 @@ def derive_overall_status(
if TaskRuntimeState.ERROR in states: if TaskRuntimeState.ERROR in states:
return "error", "Fehler" return "error", "Fehler"
if TaskRuntimeState.WAITING_FOR_RELEASE in states: if TaskRuntimeState.WAITING_FOR_RELEASE in states:
return "error", "Synchronisiert" if running else "Unvollständig – Dateien gesperrt" return "error", "Synchronisierung läuft" if running else "Unvollständig – Dateien gesperrt"
if globally_paused: if globally_paused:
return "paused", "Pausiert" return "paused", "Pausiert"
if TaskRuntimeState.ACTIVE not in states: if TaskRuntimeState.ACTIVE not in states:
return "paused", "Inaktiv" return "paused", "Inaktiv"
return "active", "Synchronisiert" if running else "Bereit" return "active", "Synchronisierung läuft" if running else "Bereit"
class _WorkerSignals(QObject): class _WorkerSignals(QObject):
@@ -186,6 +186,7 @@ class SyncCoordinator(QObject):
self._start_next() self._start_next()
return return
self._running_task_id = task_id self._running_task_id = task_id
self.progress_changed.emit(0, f"{task.name}: Synchronisation wird gestartet …")
self.idle_changed.emit(False) self.idle_changed.emit(False)
self._emit_overall_status() self._emit_overall_status()
self.log_created.emit(task.name, "Start", "Synchronisation gestartet.") self.log_created.emit(task.name, "Start", "Synchronisation gestartet.")
+35 -4
View File
@@ -4,11 +4,13 @@ from __future__ import annotations
import os import os
import shutil import shutil
import stat
from collections.abc import Callable from collections.abc import Callable
from contextlib import suppress from contextlib import suppress
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from time import monotonic
from syncvito.file_locks import LockingProcess, find_locking_processes, is_file_locked from syncvito.file_locks import LockingProcess, find_locking_processes, is_file_locked
from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncTask from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncTask
@@ -89,9 +91,13 @@ class SyncEngine:
task.validate() task.validate()
source_root = Path(task.source) source_root = Path(task.source)
target_root = Path(task.target) target_root = Path(task.target)
if progress:
progress(0, f"{task.name}: Ordner werden geprüft …")
self._validate_roots(source_root, target_root) self._validate_roots(source_root, target_root)
source_files, source_dirs = self._inventory(source_root, task.selected_paths) source_files, source_dirs = self._inventory(source_root, task.selected_paths, progress, "Quelle")
target_files, target_dirs = self._inventory(target_root, task.selected_paths) target_files, target_dirs = self._inventory(target_root, task.selected_paths, progress, "Ziel")
if progress:
progress(0, f"{task.name}: Änderungen werden verglichen …")
previous = self.state_store.get(task.id) previous = self.state_store.get(task.id)
if task.direction is SyncDirection.ONE_WAY: if task.direction is SyncDirection.ONE_WAY:
@@ -157,15 +163,31 @@ class SyncEngine:
raise UnavailablePathError(f"Ziel ist kein Ordner: {target_root}") raise UnavailablePathError(f"Ziel ist kein Ordner: {target_root}")
@staticmethod @staticmethod
def _inventory(root: Path, selected_paths: list[str]) -> tuple[dict[str, FileRecord], set[str]]: def _inventory(
root: Path,
selected_paths: list[str],
progress: ProgressCallback | None = None,
label: str = "Ordner",
) -> tuple[dict[str, FileRecord], set[str]]:
files: dict[str, FileRecord] = {} files: dict[str, FileRecord] = {}
directories: set[str] = set() directories: set[str] = set()
selections = tuple(Path(item).parts for item in selected_paths) selections = tuple(Path(item).parts for item in selected_paths)
last_report = monotonic()
if progress:
progress(0, f"{label} wird eingelesen: {root}")
try: try:
for current, dir_names, file_names in os.walk(root): for current, dir_names, file_names in os.walk(root):
current_path = Path(current) current_path = Path(current)
relative_dir = current_path.relative_to(root) relative_dir = current_path.relative_to(root)
dir_names[:] = [name for name in dir_names if not (current_path / name).is_symlink()] dir_names[:] = [
name
for name in dir_names
if _is_selected((relative_dir / name).parts, selections)
and not _is_directory_link(current_path / name)
]
if progress and monotonic() - last_report >= 0.25:
progress(0, f"{label}: {len(files)} Dateien gefunden – {current_path}")
last_report = monotonic()
for directory_name in dir_names: for directory_name in dir_names:
relative = relative_dir / directory_name relative = relative_dir / directory_name
if _is_selected(relative.parts, selections): if _is_selected(relative.parts, selections):
@@ -182,6 +204,9 @@ class SyncEngine:
except OSError: except OSError:
continue continue
files[str(relative)] = FileRecord(path, file_stat.st_size, file_stat.st_mtime_ns) files[str(relative)] = FileRecord(path, file_stat.st_size, file_stat.st_mtime_ns)
if progress and monotonic() - last_report >= 0.25:
progress(0, f"{label}: {len(files)} Dateien gefunden – {current_path}")
last_report = monotonic()
except OSError as exc: except OSError as exc:
raise UnavailablePathError(f"Ordner kann nicht gelesen werden: {root}") from exc raise UnavailablePathError(f"Ordner kann nicht gelesen werden: {root}") from exc
return files, directories return files, directories
@@ -303,6 +328,12 @@ class SyncEngine:
} }
def _is_directory_link(path: Path) -> bool:
"""Erkennt auch Windows-Junctions unter Python 3.11 ohne Path.is_junction."""
metadata = path.lstat()
return stat.S_ISLNK(metadata.st_mode) or getattr(metadata, "st_reparse_tag", 0) == stat.IO_REPARSE_TAG_MOUNT_POINT
def _blocked_path(operation: _Operation, exc: OSError | shutil.Error) -> Path | None: def _blocked_path(operation: _Operation, exc: OSError | shutil.Error) -> Path | None:
"""Ordnet auch Windows-Kopierfehler ohne Dateinamen der gesperrten Seite zu.""" """Ordnet auch Windows-Kopierfehler ohne Dateinamen der gesperrten Seite zu."""
winerror = getattr(exc, "winerror", None) winerror = getattr(exc, "winerror", None)
+20
View File
@@ -73,3 +73,23 @@ def test_retry_queues_all_ready_tasks(coordinator: SyncCoordinator, monkeypatch:
monkeypatch.setattr(coordinator, "enqueue", queued.append) monkeypatch.setattr(coordinator, "enqueue", queued.append)
coordinator._check_retries() coordinator._check_retries()
assert queued == ["first", "second"] assert queued == ["first", "second"]
def test_start_resets_stale_progress_before_worker_starts(
coordinator: SyncCoordinator, monkeypatch: pytest.MonkeyPatch
) -> None:
from types import SimpleNamespace
messages = []
workers = []
coordinator.progress_changed.connect(lambda *entry: messages.append(entry))
def start(worker) -> None:
assert messages and messages[-1][0] == 0
assert "AppData" in messages[-1][1]
workers.append(worker)
monkeypatch.setattr(coordinator, "pool", SimpleNamespace(start=start))
coordinator.enqueue(coordinator.config.tasks[0].id)
assert len(workers) == 1
assert coordinator.current_overall_status() == ("active", "Synchronisierung läuft")
+1 -1
View File
@@ -20,7 +20,7 @@ def test_waiting_lock_has_priority_over_active_task() -> None:
def test_waiting_task_does_not_hide_running_sync() -> None: def test_waiting_task_does_not_hide_running_sync() -> None:
assert derive_overall_status([TaskRuntimeState.WAITING_FOR_RELEASE], False, running=True) == ( assert derive_overall_status([TaskRuntimeState.WAITING_FOR_RELEASE], False, running=True) == (
"error", "error",
"Synchronisiert", "Synchronisierung läuft",
) )
+66
View File
@@ -12,6 +12,72 @@ def _engine(tmp_path: Path) -> SyncEngine:
return SyncEngine(StateStore(tmp_path / "state.json")) return SyncEngine(StateStore(tmp_path / "state.json"))
def test_progress_reports_scan_before_copying(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
from itertools import count
source = tmp_path / "source"
target = tmp_path / "target"
source.mkdir()
(source / "datei.txt").write_text("Inhalt")
ticks = count()
monkeypatch.setattr("syncvito.sync_engine.monotonic", lambda: float(next(ticks)))
messages = []
def progress(value: int, detail: str) -> None:
messages.append((value, detail))
if "eingelesen" in detail or "Dateien gefunden" in detail:
assert value == 0
assert not (target / "datei.txt").exists()
result = _engine(tmp_path).synchronize(SyncTask(name="Test", source=str(source), target=str(target)), progress)
assert result.successful
assert "Ordner werden geprüft" in messages[0][1]
assert any("Quelle wird eingelesen" in detail for _, detail in messages)
assert any("Ziel wird eingelesen" in detail for _, detail in messages)
assert any("1 Dateien gefunden" in detail for _, detail in messages)
assert messages[-1][0] == 100
@pytest.mark.skipif(os.name != "nt", reason="Windows-Junctions")
def test_inventory_does_not_follow_windows_junctions(tmp_path: Path) -> None:
import _winapi
source = tmp_path / "source"
outside = tmp_path / "outside"
source.mkdir()
outside.mkdir()
(outside / "extern.txt").write_text("Nicht synchronisieren")
(source / "lokal.txt").write_text("Synchronisieren")
junction = source / "verknuepfung"
_winapi.CreateJunction(str(outside), str(junction))
try:
files, directories = SyncEngine._inventory(source, [])
assert set(files) == {"lokal.txt"}
assert "verknuepfung" not in directories
finally:
junction.rmdir()
def test_inventory_prunes_unselected_subtrees(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
source = tmp_path / "source"
(source / "auswahl").mkdir(parents=True)
(source / "ignorieren" / "unterordner").mkdir(parents=True)
(source / "auswahl" / "ja.txt").write_text("ja")
visited = []
original_walk = os.walk
def walk(*args, **kwargs):
for entry in original_walk(*args, **kwargs):
visited.append(Path(entry[0]))
yield entry
monkeypatch.setattr("syncvito.sync_engine.os.walk", walk)
files, _ = SyncEngine._inventory(source, ["auswahl"])
assert set(files) == {str(Path("auswahl/ja.txt"))}
assert visited == [source, source / "auswahl"]
@pytest.mark.parametrize("winerror", [None, 5]) @pytest.mark.parametrize("winerror", [None, 5])
def test_access_denied_is_not_reported_as_file_lock( def test_access_denied_is_not_reported_as_file_lock(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, winerror: int | None tmp_path: Path, monkeypatch: pytest.MonkeyPatch, winerror: int | None
+1 -1
View File
@@ -1 +1 @@
1.0.8 1.0.9