Initial SyncVito release with installer updater
This commit is contained in:
commit
b9e4c09f49
41 files changed
+4331
No files matched your search
@@ -0,0 +1,328 @@
|
||||
"""Dateisystem-Synchronisation ohne Abhängigkeit von der Benutzeroberfläche."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from syncvito.file_locks import LockingProcess, find_locking_processes
|
||||
from syncvito.models import ConflictMode, DeleteMode, SyncDirection, SyncTask
|
||||
from syncvito.state_store import StateStore
|
||||
|
||||
__all__ = [
|
||||
"BlockedFile",
|
||||
"ProgressCallback",
|
||||
"SyncEngine",
|
||||
"SyncResult",
|
||||
"UnavailablePathError",
|
||||
]
|
||||
|
||||
ProgressCallback = Callable[[int, str], None]
|
||||
|
||||
|
||||
class UnavailablePathError(RuntimeError):
|
||||
"""Ein für die Synchronisation benötigter Pfad ist nicht erreichbar."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FileRecord:
|
||||
"""Kompakte Dateisystemmetadaten für Vergleiche."""
|
||||
|
||||
path: Path
|
||||
size: int
|
||||
mtime_ns: int
|
||||
|
||||
@property
|
||||
def signature(self) -> str:
|
||||
"""Liefert eine persistierbare Signatur."""
|
||||
return f"{self.size}:{self.mtime_ns}"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BlockedFile:
|
||||
"""Eine zurückgestellte Datei einschließlich bekannter Sperrprozesse."""
|
||||
|
||||
path: Path
|
||||
processes: tuple[LockingProcess, ...] = ()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SyncResult:
|
||||
"""Zusammenfassung eines vollständigen Aufgabenlaufs."""
|
||||
|
||||
copied: int = 0
|
||||
deleted: int = 0
|
||||
conflicts: int = 0
|
||||
skipped: int = 0
|
||||
blocked: list[BlockedFile] = field(default_factory=list)
|
||||
errors: list[str] = field(default_factory=list)
|
||||
inaccessible: bool = False
|
||||
|
||||
@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
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Operation:
|
||||
kind: str
|
||||
source: Path | None
|
||||
target: Path
|
||||
relative: str
|
||||
conflict_copy: bool = False
|
||||
|
||||
|
||||
class SyncEngine:
|
||||
"""Plant und verarbeitet Einweg- sowie bidirektionale Aufgaben."""
|
||||
|
||||
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:
|
||||
"""Führt eine Aufgabe aus und isoliert Fehler auf einzelne Dateien."""
|
||||
task.validate()
|
||||
source_root = Path(task.source)
|
||||
target_root = Path(task.target)
|
||||
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)
|
||||
previous = self.state_store.get(task.id)
|
||||
|
||||
if task.direction is SyncDirection.ONE_WAY:
|
||||
operations = self._plan_one_way(
|
||||
task, source_root, target_root, source_files, target_files, source_dirs, target_dirs
|
||||
)
|
||||
else:
|
||||
operations = self._plan_bidirectional(
|
||||
task,
|
||||
source_root,
|
||||
target_root,
|
||||
source_files,
|
||||
target_files,
|
||||
source_dirs,
|
||||
target_dirs,
|
||||
previous,
|
||||
)
|
||||
|
||||
result = SyncResult()
|
||||
total = max(1, len(operations))
|
||||
if progress:
|
||||
progress(0, "Änderungen werden vorbereitet …")
|
||||
for index, operation in enumerate(operations, start=1):
|
||||
try:
|
||||
self._execute(operation, result)
|
||||
except PermissionError as exc:
|
||||
processes = tuple(find_locking_processes(operation.target))
|
||||
result.blocked.append(BlockedFile(operation.target, processes))
|
||||
result.errors.append(f"Zugriff verweigert: {operation.target} ({exc})")
|
||||
except (OSError, shutil.Error) as exc:
|
||||
result.errors.append(f"{operation.target}: {exc}")
|
||||
if progress:
|
||||
detail = f"{operation.kind}: {operation.relative}"
|
||||
progress(round(index / total * 100), detail)
|
||||
|
||||
if not operations and progress:
|
||||
progress(100, "Keine Änderungen gefunden")
|
||||
|
||||
if task.direction is SyncDirection.BIDIRECTIONAL and result.successful:
|
||||
refreshed_source, _ = self._inventory(source_root, task.selected_paths)
|
||||
refreshed_target, _ = self._inventory(target_root, task.selected_paths)
|
||||
self.state_store.set(task.id, self._state_from_inventories(refreshed_source, refreshed_target))
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _validate_roots(source_root: Path, target_root: Path) -> None:
|
||||
if not source_root.is_dir():
|
||||
raise UnavailablePathError(f"Quellordner nicht erreichbar: {source_root}")
|
||||
if not target_root.exists():
|
||||
try:
|
||||
target_root.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
raise UnavailablePathError(f"Zielordner nicht erreichbar: {target_root}") from exc
|
||||
if not target_root.is_dir():
|
||||
raise UnavailablePathError(f"Ziel ist kein Ordner: {target_root}")
|
||||
|
||||
@staticmethod
|
||||
def _inventory(root: Path, selected_paths: list[str]) -> tuple[dict[str, FileRecord], set[str]]:
|
||||
files: dict[str, FileRecord] = {}
|
||||
directories: set[str] = set()
|
||||
selections = tuple(Path(item).parts for item in selected_paths)
|
||||
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()]
|
||||
for directory_name in dir_names:
|
||||
relative = relative_dir / directory_name
|
||||
if _is_selected(relative.parts, selections):
|
||||
directories.add(str(relative))
|
||||
for file_name in file_names:
|
||||
path = current_path / file_name
|
||||
if path.is_symlink():
|
||||
continue
|
||||
relative = path.relative_to(root)
|
||||
if not _is_selected(relative.parts, selections):
|
||||
continue
|
||||
try:
|
||||
file_stat = path.stat()
|
||||
except OSError:
|
||||
continue
|
||||
files[str(relative)] = FileRecord(path, file_stat.st_size, file_stat.st_mtime_ns)
|
||||
except OSError as exc:
|
||||
raise UnavailablePathError(f"Ordner kann nicht gelesen werden: {root}") from exc
|
||||
return files, directories
|
||||
|
||||
@staticmethod
|
||||
def _plan_one_way(
|
||||
task: SyncTask,
|
||||
source_root: Path,
|
||||
target_root: Path,
|
||||
source_files: dict[str, FileRecord],
|
||||
target_files: dict[str, FileRecord],
|
||||
source_dirs: set[str],
|
||||
target_dirs: set[str],
|
||||
) -> list[_Operation]:
|
||||
operations: list[_Operation] = []
|
||||
for relative in sorted(source_dirs - target_dirs):
|
||||
operations.append(_Operation("Ordner anlegen", None, target_root / relative, relative))
|
||||
for relative, source in sorted(source_files.items()):
|
||||
target = target_files.get(relative)
|
||||
if target is None or target.signature != source.signature:
|
||||
operations.append(_Operation("Kopieren", source.path, target_root / relative, relative))
|
||||
if task.delete_mode is DeleteMode.MIRROR:
|
||||
for relative in sorted(target_files.keys() - source_files.keys()):
|
||||
operations.append(_Operation("Löschen", None, target_root / relative, relative))
|
||||
for relative in sorted(target_dirs - source_dirs, key=lambda item: len(Path(item).parts), reverse=True):
|
||||
operations.append(_Operation("Ordner löschen", None, target_root / relative, relative))
|
||||
return operations
|
||||
|
||||
@staticmethod
|
||||
def _plan_bidirectional(
|
||||
task: SyncTask,
|
||||
source_root: Path,
|
||||
target_root: Path,
|
||||
source_files: dict[str, FileRecord],
|
||||
target_files: dict[str, FileRecord],
|
||||
source_dirs: set[str],
|
||||
target_dirs: set[str],
|
||||
previous: dict[str, dict[str, str | None]],
|
||||
) -> list[_Operation]:
|
||||
operations = [
|
||||
_Operation("Ordner anlegen", None, target_root / relative, relative)
|
||||
for relative in sorted(source_dirs - target_dirs)
|
||||
]
|
||||
operations.extend(
|
||||
_Operation("Ordner anlegen", None, source_root / relative, relative)
|
||||
for relative in sorted(target_dirs - source_dirs)
|
||||
)
|
||||
all_paths = sorted(source_files.keys() | target_files.keys() | previous.keys())
|
||||
for relative in all_paths:
|
||||
source = source_files.get(relative)
|
||||
target = target_files.get(relative)
|
||||
old = previous.get(relative, {})
|
||||
old_source = old.get("source")
|
||||
old_target = old.get("target")
|
||||
if source and target:
|
||||
if source.signature == target.signature:
|
||||
continue
|
||||
winner, destination = _newer_pair(source, target)
|
||||
conflict = task.conflict_mode is ConflictMode.CONFLICT_COPY
|
||||
operations.append(
|
||||
_Operation(
|
||||
"Konflikt lösen" if conflict else "Aktualisieren",
|
||||
winner.path,
|
||||
destination.path,
|
||||
relative,
|
||||
conflict,
|
||||
)
|
||||
)
|
||||
elif source:
|
||||
target_was_deleted = old_target is not None and source.signature == old_source
|
||||
if task.delete_mode is DeleteMode.MIRROR and target_was_deleted:
|
||||
operations.append(_Operation("Löschung spiegeln", None, source.path, relative))
|
||||
else:
|
||||
operations.append(_Operation("Kopieren", source.path, target_root / relative, relative))
|
||||
elif target:
|
||||
source_was_deleted = old_source is not None and target.signature == old_target
|
||||
if task.delete_mode is DeleteMode.MIRROR and source_was_deleted:
|
||||
operations.append(_Operation("Löschung spiegeln", None, target.path, relative))
|
||||
else:
|
||||
operations.append(_Operation("Kopieren", target.path, source_root / relative, relative))
|
||||
return operations
|
||||
|
||||
@staticmethod
|
||||
def _execute(operation: _Operation, result: SyncResult) -> None:
|
||||
if operation.kind == "Ordner anlegen":
|
||||
operation.target.mkdir(parents=True, exist_ok=True)
|
||||
return
|
||||
if operation.kind in {"Löschen", "Löschung spiegeln"}:
|
||||
operation.target.unlink(missing_ok=True)
|
||||
result.deleted += 1
|
||||
return
|
||||
if operation.kind == "Ordner löschen":
|
||||
try:
|
||||
operation.target.rmdir()
|
||||
result.deleted += 1
|
||||
except OSError:
|
||||
result.skipped += 1
|
||||
return
|
||||
if operation.source is None:
|
||||
return
|
||||
operation.target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if operation.conflict_copy and operation.target.exists():
|
||||
conflict_path = _conflict_path(operation.target)
|
||||
shutil.copy2(operation.target, conflict_path)
|
||||
result.conflicts += 1
|
||||
_atomic_copy(operation.source, operation.target)
|
||||
result.copied += 1
|
||||
|
||||
@staticmethod
|
||||
def _state_from_inventories(
|
||||
source_files: dict[str, FileRecord], target_files: dict[str, FileRecord]
|
||||
) -> dict[str, dict[str, str | None]]:
|
||||
return {
|
||||
relative: {
|
||||
"source": source_files[relative].signature if relative in source_files else None,
|
||||
"target": target_files[relative].signature if relative in target_files else None,
|
||||
}
|
||||
for relative in source_files.keys() | target_files.keys()
|
||||
}
|
||||
|
||||
|
||||
def _atomic_copy(source: Path, target: Path) -> None:
|
||||
temporary = target.with_name(f".{target.name}.syncvito.tmp")
|
||||
try:
|
||||
shutil.copy2(source, temporary)
|
||||
os.replace(temporary, target)
|
||||
except BaseException:
|
||||
with suppress(OSError):
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def _conflict_path(path: Path) -> Path:
|
||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
candidate = path.with_name(f"{path.stem}_conflict_{timestamp}{path.suffix}")
|
||||
counter = 2
|
||||
while candidate.exists():
|
||||
candidate = path.with_name(f"{path.stem}_conflict_{timestamp}_{counter}{path.suffix}")
|
||||
counter += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def _newer_pair(left: FileRecord, right: FileRecord) -> tuple[FileRecord, FileRecord]:
|
||||
if left.mtime_ns >= right.mtime_ns:
|
||||
return left, right
|
||||
return right, left
|
||||
|
||||
|
||||
def _is_selected(parts: tuple[str, ...], selections: tuple[tuple[str, ...], ...]) -> bool:
|
||||
if not selections:
|
||||
return True
|
||||
return any(parts[: len(selection)] == selection or selection[: len(parts)] == parts for selection in selections)
|
||||
Reference in new issue
Block a user