320 lines
9.8 KiB
Python
Executable File
320 lines
9.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Mark files for backup, then copy them to an external drive and remove
|
|
all their hardlinked siblings once the copy is verified.
|
|
|
|
Usage:
|
|
backup_tool.py mark <file> [file...] stage a file for backup
|
|
backup_tool.py unmark <file> [file...] drop a file from staging
|
|
backup_tool.py list show what's staged
|
|
backup_tool.py run [--yes] copy staged files to the drive,
|
|
then delete originals + siblings
|
|
|
|
Config (override via environment):
|
|
BACKUP_STAGING_DIR default: ~/.backup-staging
|
|
|
|
Destination routing is controlled by the RULES table below — edit it to
|
|
change which drive/subdirectory a file backs up to based on its source path
|
|
and type.
|
|
"""
|
|
|
|
import argparse
|
|
import hashlib
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from collections.abc import Callable
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
STAGING_DIR = Path(
|
|
os.environ.get("BACKUP_STAGING_DIR", str(Path.home() / ".backup-staging"))
|
|
)
|
|
|
|
VIDEO_EXTS = {".mp4", ".mov", ".mkv", ".avi", ".m4v", ".webm"}
|
|
|
|
# Roots under which an external drive's mount point may appear. Native
|
|
# drives (HFS+/APFS) mount under /Volumes; Mounty mounts NTFS drives
|
|
# read-write under ~/.mounty instead.
|
|
MOUNT_ROOTS = [Path("/Volumes"), Path.home() / ".mounty"]
|
|
|
|
|
|
def is_video(path: Path) -> bool:
|
|
return path.suffix.lower() in VIDEO_EXTS
|
|
|
|
|
|
@dataclass
|
|
class Rule:
|
|
prefix: Path # source files under this path are matched
|
|
volume: str # drive name as it appears under a mount root, e.g. "FIVER"
|
|
subdir: str # path under the volume root to copy into, e.g. "_ingress"
|
|
match_file: Callable[[Path], bool] | None = field(
|
|
default=None
|
|
) # None matches any file
|
|
|
|
def candidate_mount_points(self) -> list[Path]:
|
|
"""Where this volume might be mounted: a regular /Volumes mount, or
|
|
a Mounty read-write remount under ~/.mounty."""
|
|
return [root / self.volume for root in MOUNT_ROOTS]
|
|
|
|
def mounted_at(self) -> Path | None:
|
|
"""Whichever candidate mount point is actually mounted right now, or None."""
|
|
for mount_point in self.candidate_mount_points():
|
|
if is_mounted(mount_point):
|
|
return mount_point
|
|
return None
|
|
|
|
def matches(self, path: Path) -> bool:
|
|
if not path.is_relative_to(self.prefix):
|
|
return False
|
|
if self.match_file is not None and not self.match_file(path):
|
|
return False
|
|
return True
|
|
|
|
def destination_for(self, path: Path, mount_point: Path) -> Path:
|
|
rel = path.relative_to(self.prefix)
|
|
return mount_point / self.subdir / rel
|
|
|
|
|
|
# Order matters: first matching rule wins.
|
|
RULES = [
|
|
Rule(
|
|
prefix=Path.home() / "Downloads/temp",
|
|
volume="FIVER",
|
|
subdir="_ingress",
|
|
match_file=is_video,
|
|
),
|
|
Rule(
|
|
prefix=Path.home() / "Movies",
|
|
volume="FAST",
|
|
subdir="_movies",
|
|
),
|
|
]
|
|
|
|
|
|
def rule_for(path: Path) -> Rule | None:
|
|
for rule in RULES:
|
|
if rule.matches(path):
|
|
return rule
|
|
return None
|
|
|
|
|
|
def die(msg: str) -> None:
|
|
print(f"error: {msg}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def staged_path_for(abs_path: Path) -> Path:
|
|
return STAGING_DIR / abs_path.relative_to(abs_path.anchor)
|
|
|
|
|
|
def original_path_for(staged_path: Path) -> Path:
|
|
return Path("/") / staged_path.relative_to(STAGING_DIR)
|
|
|
|
|
|
def sha256(path: Path) -> str:
|
|
h = hashlib.sha256()
|
|
with open(path, "rb") as f:
|
|
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
|
h.update(chunk)
|
|
return h.hexdigest()
|
|
|
|
|
|
def mountpoint_of(path: Path) -> str:
|
|
out = subprocess.run(
|
|
["df", str(path)], capture_output=True, text=True, check=True
|
|
).stdout
|
|
return out.strip().splitlines()[-1].split()[-1]
|
|
|
|
|
|
def mount_line(mount_point: Path) -> str | None:
|
|
out = subprocess.run(["mount"], capture_output=True, text=True, check=True).stdout
|
|
for line in out.splitlines():
|
|
if f" on {mount_point} " in line:
|
|
return line
|
|
return None
|
|
|
|
|
|
def is_mounted(mount_point: Path) -> bool:
|
|
return mount_line(mount_point) is not None
|
|
|
|
|
|
def is_read_only(mount_point: Path) -> bool:
|
|
"""True if mounted read-only — the state NTFS drives auto-mount into
|
|
before Mounty remounts them read-write via ntfs-3g."""
|
|
line = mount_line(mount_point)
|
|
return line is not None and "read-only" in line
|
|
|
|
|
|
def find_siblings(path: Path) -> list[Path]:
|
|
"""All paths sharing this file's inode, scoped to its own filesystem."""
|
|
inode = path.stat().st_ino
|
|
root = mountpoint_of(path)
|
|
result = subprocess.run(
|
|
["find", root, "-xdev", "-inum", str(inode)],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
return [Path(p) for p in result.stdout.splitlines() if p]
|
|
|
|
|
|
def cmd_mark(args: argparse.Namespace) -> None:
|
|
if not args.files:
|
|
die("usage: mark <file> [file...]")
|
|
for f in args.files:
|
|
p = Path(f)
|
|
if not p.is_file():
|
|
print(f"skip (not a regular file): {f}", file=sys.stderr)
|
|
continue
|
|
abs_path = p.resolve()
|
|
if rule_for(abs_path) is None:
|
|
die(f"no backup rule matches '{abs_path}' — add one to RULES")
|
|
dest = staged_path_for(abs_path)
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
if dest.exists():
|
|
print(f"already marked: {abs_path}")
|
|
continue
|
|
try:
|
|
os.link(abs_path, dest)
|
|
except OSError as e:
|
|
die(
|
|
f"could not hardlink '{abs_path}' into staging "
|
|
f"(staging dir must be on the same filesystem as the file): {e}"
|
|
)
|
|
print(f"marked: {abs_path}")
|
|
|
|
|
|
def cmd_unmark(args: argparse.Namespace) -> None:
|
|
if not args.files:
|
|
die("usage: unmark <file> [file...]")
|
|
for f in args.files:
|
|
abs_path = Path(f).resolve()
|
|
staged = staged_path_for(abs_path)
|
|
if staged.exists():
|
|
staged.unlink()
|
|
print(f"unmarked: {abs_path}")
|
|
else:
|
|
print(f"not marked: {abs_path}", file=sys.stderr)
|
|
|
|
|
|
def cmd_list(_args: argparse.Namespace) -> None:
|
|
if not STAGING_DIR.is_dir():
|
|
print("(nothing staged)")
|
|
return
|
|
staged = sorted(p for p in STAGING_DIR.rglob("*") if p.is_file())
|
|
if not staged:
|
|
print("(nothing staged)")
|
|
return
|
|
for p in staged:
|
|
print("/" + str(p.relative_to(STAGING_DIR)))
|
|
|
|
|
|
def cmd_run(args: argparse.Namespace) -> None:
|
|
if not STAGING_DIR.is_dir():
|
|
print("(nothing staged)")
|
|
return
|
|
staged = sorted(p for p in STAGING_DIR.rglob("*") if p.is_file())
|
|
if not staged:
|
|
print("(nothing staged)")
|
|
return
|
|
|
|
# Resolve each staged file's destination; skip (with a message) anything
|
|
# whose target rule can no longer be matched or whose volume isn't mounted.
|
|
plan = []
|
|
for src in staged:
|
|
original = original_path_for(src)
|
|
rule = rule_for(original)
|
|
if rule is None:
|
|
print(f"skipping (no rule matches): {original}", file=sys.stderr)
|
|
continue
|
|
mount_point = rule.mounted_at()
|
|
if mount_point is None:
|
|
print(f"skipping (drive '{rule.volume}' not mounted): {original}")
|
|
continue
|
|
if is_read_only(mount_point):
|
|
print(
|
|
f"skipping (drive '{rule.volume}' is mounted read-only — open Mounty "
|
|
f"and choose 'Remount' to enable write access): {original}"
|
|
)
|
|
continue
|
|
dest = rule.destination_for(original, mount_point)
|
|
plan.append((src, dest))
|
|
|
|
if not plan:
|
|
print("nothing to do — connect the relevant drive(s) and run again.")
|
|
return
|
|
|
|
print("The following staged files will be copied to their destinations,")
|
|
print(
|
|
"then ALL hardlinked copies of each (including the originals) will be deleted:"
|
|
)
|
|
for _src, dest in plan:
|
|
print(f" -> {dest}")
|
|
print()
|
|
|
|
if not args.yes:
|
|
reply = input("Proceed? [y/N] ").strip().lower()
|
|
if reply not in ("y", "yes"):
|
|
print("aborted.")
|
|
sys.exit(1)
|
|
|
|
for src, dest in plan:
|
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
print(f"copying: {original_path_for(src)} -> {dest}")
|
|
shutil.copy2(src, dest)
|
|
|
|
if sha256(src) != sha256(dest):
|
|
print(
|
|
f" verification FAILED, leaving originals in place: {original_path_for(src)}",
|
|
file=sys.stderr,
|
|
)
|
|
continue
|
|
|
|
siblings = find_siblings(src)
|
|
print(f" verified. removing {len(siblings)} hardlinked path(s):")
|
|
for sib in siblings:
|
|
print(f" {sib}")
|
|
for sib in siblings:
|
|
try:
|
|
sib.unlink()
|
|
except OSError as e:
|
|
print(f" failed to remove {sib}: {e}", file=sys.stderr)
|
|
|
|
# Clean up now-empty staging directories.
|
|
for dirpath, dirnames, filenames in os.walk(STAGING_DIR, topdown=False):
|
|
d = Path(dirpath)
|
|
if d != STAGING_DIR and not any(d.iterdir()):
|
|
d.rmdir()
|
|
|
|
print("done.")
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
|
)
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
|
|
p_mark = sub.add_parser("mark")
|
|
p_mark.add_argument("files", nargs="+")
|
|
p_mark.set_defaults(func=cmd_mark)
|
|
|
|
p_unmark = sub.add_parser("unmark")
|
|
p_unmark.add_argument("files", nargs="+")
|
|
p_unmark.set_defaults(func=cmd_unmark)
|
|
|
|
p_list = sub.add_parser("list")
|
|
p_list.set_defaults(func=cmd_list)
|
|
|
|
p_run = sub.add_parser("run")
|
|
p_run.add_argument("--yes", action="store_true", help="skip confirmation prompt")
|
|
p_run.set_defaults(func=cmd_run)
|
|
|
|
args = parser.parse_args()
|
|
args.func(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|