Release SyncVito 1.0.9
This commit is contained in:
1 parent
bb5bfda4fe
commit
4efc1efa18
8 files changed
+136
-9
No files matched your search
@@ -1,5 +1,14 @@
|
||||
# Ä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
|
||||
|
||||
### Behoben
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "syncvito"
|
||||
version = "1.0.8"
|
||||
version = "1.0.9"
|
||||
description = "Windows-Desktopwerkzeug für lokale und NAS-Synchronisation"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -25,12 +25,12 @@ def derive_overall_status(
|
||||
if TaskRuntimeState.ERROR in states:
|
||||
return "error", "Fehler"
|
||||
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:
|
||||
return "paused", "Pausiert"
|
||||
if TaskRuntimeState.ACTIVE not in states:
|
||||
return "paused", "Inaktiv"
|
||||
return "active", "Synchronisiert" if running else "Bereit"
|
||||
return "active", "Synchronisierung läuft" if running else "Bereit"
|
||||
|
||||
|
||||
class _WorkerSignals(QObject):
|
||||
@@ -186,6 +186,7 @@ class SyncCoordinator(QObject):
|
||||
self._start_next()
|
||||
return
|
||||
self._running_task_id = task_id
|
||||
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.")
|
||||
|
||||
@@ -4,11 +4,13 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from time import monotonic
|
||||
|
||||
from syncvito.file_locks import LockingProcess, find_locking_processes, is_file_locked
|
||||
from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncTask
|
||||
@@ -89,9 +91,13 @@ class SyncEngine:
|
||||
task.validate()
|
||||
source_root = Path(task.source)
|
||||
target_root = Path(task.target)
|
||||
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)
|
||||
target_files, target_dirs = self._inventory(target_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, progress, "Ziel")
|
||||
if progress:
|
||||
progress(0, f"{task.name}: Änderungen werden verglichen …")
|
||||
previous = self.state_store.get(task.id)
|
||||
|
||||
if task.direction is SyncDirection.ONE_WAY:
|
||||
@@ -157,15 +163,31 @@ class SyncEngine:
|
||||
raise UnavailablePathError(f"Ziel ist kein Ordner: {target_root}")
|
||||
|
||||
@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] = {}
|
||||
directories: set[str] = set()
|
||||
selections = tuple(Path(item).parts for item in selected_paths)
|
||||
last_report = monotonic()
|
||||
if progress:
|
||||
progress(0, f"{label} wird eingelesen: {root}")
|
||||
try:
|
||||
for current, dir_names, file_names in os.walk(root):
|
||||
current_path = Path(current)
|
||||
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:
|
||||
relative = relative_dir / directory_name
|
||||
if _is_selected(relative.parts, selections):
|
||||
@@ -182,6 +204,9 @@ class SyncEngine:
|
||||
except OSError:
|
||||
continue
|
||||
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:
|
||||
raise UnavailablePathError(f"Ordner kann nicht gelesen werden: {root}") from exc
|
||||
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:
|
||||
"""Ordnet auch Windows-Kopierfehler ohne Dateinamen der gesperrten Seite zu."""
|
||||
winerror = getattr(exc, "winerror", None)
|
||||
|
||||
@@ -73,3 +73,23 @@ def test_retry_queues_all_ready_tasks(coordinator: SyncCoordinator, monkeypatch:
|
||||
monkeypatch.setattr(coordinator, "enqueue", queued.append)
|
||||
coordinator._check_retries()
|
||||
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")
|
||||
@@ -20,7 +20,7 @@ def test_waiting_lock_has_priority_over_active_task() -> None:
|
||||
def test_waiting_task_does_not_hide_running_sync() -> None:
|
||||
assert derive_overall_status([TaskRuntimeState.WAITING_FOR_RELEASE], False, running=True) == (
|
||||
"error",
|
||||
"Synchronisiert",
|
||||
"Synchronisierung läuft",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,72 @@ def _engine(tmp_path: Path) -> SyncEngine:
|
||||
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])
|
||||
def test_access_denied_is_not_reported_as_file_lock(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, winerror: int | None
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
1.0.8
|
||||
1.0.9
|
||||
Reference in new issue
Block a user