From 4efc1efa187368fcdaf0e2d598efca2ff1aae562 Mon Sep 17 00:00:00 2001 From: Davito <1+davito@noreply.localhost> Date: Thu, 24 Sep 2026 16:46:22 +0200 Subject: [PATCH] Release SyncVito 1.0.9 --- CHANGELOG.md | 9 +++++ pyproject.toml | 2 +- src/syncvito/coordinator.py | 5 +-- src/syncvito/sync_engine.py | 39 ++++++++++++++++++--- tests/test_coordinator.py | 20 +++++++++++ tests/test_runtime_status.py | 2 +- tests/test_sync_engine.py | 66 ++++++++++++++++++++++++++++++++++++ version.txt | 2 +- 8 files changed, 136 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f00706..0475883 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index a985b0d..5813919 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/syncvito/coordinator.py b/src/syncvito/coordinator.py index 62ae363..0f3f496 100644 --- a/src/syncvito/coordinator.py +++ b/src/syncvito/coordinator.py @@ -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.") diff --git a/src/syncvito/sync_engine.py b/src/syncvito/sync_engine.py index cb87243..abf259a 100644 --- a/src/syncvito/sync_engine.py +++ b/src/syncvito/sync_engine.py @@ -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) diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index 9df09f0..8918f9d 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -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") diff --git a/tests/test_runtime_status.py b/tests/test_runtime_status.py index 7b66bf4..16d5ec7 100644 --- a/tests/test_runtime_status.py +++ b/tests/test_runtime_status.py @@ -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", ) diff --git a/tests/test_sync_engine.py b/tests/test_sync_engine.py index 960025f..8fbdca7 100644 --- a/tests/test_sync_engine.py +++ b/tests/test_sync_engine.py @@ -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 diff --git a/version.txt b/version.txt index b0f3d96..66c4c22 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.0.8 +1.0.9