95 lines
3.2 KiB
Python
95 lines
3.2 KiB
Python
from __future__ import annotations
|
|
|
|
import shutil
|
|
import uuid
|
|
from collections.abc import Iterable
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
|
|
|
|
def _remove_path(path: Path) -> None:
|
|
if path.is_symlink() or path.is_file():
|
|
path.unlink()
|
|
elif path.exists():
|
|
shutil.rmtree(path)
|
|
|
|
|
|
@contextmanager
|
|
def staged_directory(destination: str | Path):
|
|
"""Build a directory beside its destination and replace on success."""
|
|
target = Path(destination)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
token = uuid.uuid4().hex
|
|
staging = target.with_name(f".{target.name}.{token}.tmp")
|
|
backup = target.with_name(f".{target.name}.{token}.backup")
|
|
staging.mkdir()
|
|
committed = False
|
|
try:
|
|
yield staging
|
|
if target.exists():
|
|
target.replace(backup)
|
|
try:
|
|
staging.replace(target)
|
|
committed = True
|
|
except Exception:
|
|
if backup.exists() and not target.exists():
|
|
backup.replace(target)
|
|
raise
|
|
if backup.exists():
|
|
_remove_path(backup)
|
|
finally:
|
|
if staging.exists():
|
|
_remove_path(staging)
|
|
if not committed and backup.exists() and not target.exists():
|
|
backup.replace(target)
|
|
|
|
|
|
@contextmanager
|
|
def staged_files(
|
|
destination: str | Path,
|
|
*,
|
|
remove: Iterable[str] = (),
|
|
):
|
|
"""Stage a set of files and merge them into a directory with rollback."""
|
|
target = Path(destination)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
token = uuid.uuid4().hex
|
|
staging = target.parent / f".{target.name}.{token}.files.tmp"
|
|
backup = target.parent / f".{target.name}.{token}.files.backup"
|
|
staging.mkdir()
|
|
committed: list[Path] = []
|
|
try:
|
|
yield staging
|
|
staged = sorted(path for path in staging.iterdir() if path.is_file())
|
|
staged_names = {path.name for path in staged}
|
|
removed_names = set(remove) - staged_names
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
backup.mkdir()
|
|
try:
|
|
for name in sorted(removed_names):
|
|
destination_path = target / name
|
|
if destination_path.is_file() or destination_path.is_symlink():
|
|
destination_path.replace(backup / name)
|
|
for source in staged:
|
|
destination_path = target / source.name
|
|
if destination_path.exists() or destination_path.is_symlink():
|
|
destination_path.replace(backup / source.name)
|
|
source.replace(destination_path)
|
|
committed.append(destination_path)
|
|
except Exception:
|
|
for destination_path in reversed(committed):
|
|
_remove_path(destination_path)
|
|
for saved in backup.iterdir():
|
|
saved.replace(target / saved.name)
|
|
raise
|
|
_remove_path(backup)
|
|
finally:
|
|
if staging.exists():
|
|
_remove_path(staging)
|
|
if backup.exists():
|
|
for saved in backup.iterdir():
|
|
destination_path = target / saved.name
|
|
if not destination_path.exists():
|
|
saved.replace(destination_path)
|
|
_remove_path(backup)
|