Fixed many bugs, added CLI progress bar and completed backup method
This commit is contained in:
98
backup.py
98
backup.py
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
# backup.py: modular and lightweight backup utility
|
# backup.py: modular and lightweight backup utility
|
||||||
# Developed by Marco Cetica (c) 2018, 2023, 2024, 2026
|
# Developed by Marco Cetica (c) 2018-2026
|
||||||
#
|
#
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
@@ -18,7 +18,6 @@ from dataclasses import dataclass
|
|||||||
from typing import Generic, TypeVar, Union, Literal, List
|
from typing import Generic, TypeVar, Union, Literal, List
|
||||||
|
|
||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Ok(Generic[T]):
|
class Ok(Generic[T]):
|
||||||
"""Sum type to represent results"""
|
"""Sum type to represent results"""
|
||||||
@@ -49,20 +48,25 @@ class BackupState:
|
|||||||
|
|
||||||
class BackupProgress:
|
class BackupProgress:
|
||||||
"""Progress indicator for backup operations"""
|
"""Progress indicator for backup operations"""
|
||||||
def __init__(self, total: int, operation: str):
|
def __init__(self, total: int, operation: str) -> None:
|
||||||
self.total = total
|
self.total = total
|
||||||
self.current = 0
|
self.current = 0
|
||||||
self.operation = operation
|
self.operation = operation
|
||||||
|
|
||||||
def update(self, message: str = ""):
|
def draw_progress_bar(self, message: str = "") -> None:
|
||||||
"""Update progress"""
|
"""draw progress bar"""
|
||||||
self.current += 1
|
self.current += 1
|
||||||
percentage = (self.current / self.total) * 100 if self.total > 0 else 0
|
percentage = (self.current / self.total) * 100 if self.total > 0 else 0
|
||||||
|
|
||||||
status = f"\r{self.operation}: [{self.current}/{self.total}] {percentage: .1f}% - {message}"
|
# Create a CLI prograss bar
|
||||||
|
bar_width = 30
|
||||||
|
filled = int(bar_width * self.current / self.total)
|
||||||
|
bar = '█' * filled + '░' * (bar_width - filled)
|
||||||
|
|
||||||
|
status = f"\r :: {self.operation} [{bar}] {percentage:.1f}% ({self.current}/{self.total}) - (processing '{message}') ::"
|
||||||
print(f"\r\033[K{status}", end='', flush=True)
|
print(f"\r\033[K{status}", end='', flush=True)
|
||||||
|
|
||||||
def finish(self):
|
def finish(self) -> None:
|
||||||
"""Print new line"""
|
"""Print new line"""
|
||||||
print()
|
print()
|
||||||
|
|
||||||
@@ -111,7 +115,7 @@ class Backup:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if '=' not in line:
|
if '=' not in line:
|
||||||
print(f"Warning: invalid format at line {pos}: {line}")
|
return Err(f"invalid format at line {pos}: '{line}'")
|
||||||
|
|
||||||
label, path_str = line.split('=', 1)
|
label, path_str = line.split('=', 1)
|
||||||
path = Path(path_str.strip())
|
path = Path(path_str.strip())
|
||||||
@@ -141,7 +145,7 @@ class Backup:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
# Skip named pipes (FIFOs)
|
# Skip named pipes (FIFOs)
|
||||||
if path.stat().st_mode & 0o170000 == 0o010000:
|
if (path.stat().st_mode & 0o170000) == 0o010000:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
@@ -163,7 +167,7 @@ class Backup:
|
|||||||
return ignored_files
|
return ignored_files
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def copy_files(source: Path, destination: Path, verbose: bool) -> Result[None]:
|
def copy_files(source: Path, destination: Path) -> Result[None]:
|
||||||
"""Copy files and directories preserving their metadata"""
|
"""Copy files and directories preserving their metadata"""
|
||||||
try:
|
try:
|
||||||
# Handle single file
|
# Handle single file
|
||||||
@@ -171,9 +175,6 @@ class Backup:
|
|||||||
# Parent directory might not exists, so we try to create it first
|
# Parent directory might not exists, so we try to create it first
|
||||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
if verbose:
|
|
||||||
print(f"Copying file {source} -> {destination}")
|
|
||||||
|
|
||||||
# Copy file and its metadata
|
# Copy file and its metadata
|
||||||
shutil.copy2(source, destination)
|
shutil.copy2(source, destination)
|
||||||
|
|
||||||
@@ -186,9 +187,6 @@ class Backup:
|
|||||||
if destination.exists():
|
if destination.exists():
|
||||||
shutil.rmtree(destination)
|
shutil.rmtree(destination)
|
||||||
|
|
||||||
if verbose:
|
|
||||||
print(f"Copying directory {source} -> {destination}")
|
|
||||||
|
|
||||||
# Copy directory and its metadata.
|
# Copy directory and its metadata.
|
||||||
# We also ignore special files and we preserves links instead
|
# We also ignore special files and we preserves links instead
|
||||||
# of following them.
|
# of following them.
|
||||||
@@ -234,10 +232,11 @@ class Backup:
|
|||||||
def compute_file_hash(file_path: Path) -> Result[str]:
|
def compute_file_hash(file_path: Path) -> Result[str]:
|
||||||
"""Compute SHA256 hash of a given file"""
|
"""Compute SHA256 hash of a given file"""
|
||||||
try:
|
try:
|
||||||
|
hash_obj = hashlib.sha256()
|
||||||
with open(file_path, "rb") as f:
|
with open(file_path, "rb") as f:
|
||||||
for byte_block in iter(lambda: f.read(4096), b""):
|
for byte_block in iter(lambda: f.read(4096), b""):
|
||||||
hashlib.sha256().update(byte_block)
|
hash_obj.update(byte_block)
|
||||||
return Ok(hashlib.sha256().hexdigest())
|
return Ok(hash_obj.hexdigest())
|
||||||
except IOError as e:
|
except IOError as e:
|
||||||
return Err(f"Failed to read file {file_path}: {e}")
|
return Err(f"Failed to read file {file_path}: {e}")
|
||||||
|
|
||||||
@@ -245,7 +244,7 @@ class Backup:
|
|||||||
def create_tarball(source_dir: Path, output_file: Path, verbose: bool) -> Result[None]:
|
def create_tarball(source_dir: Path, output_file: Path, verbose: bool) -> Result[None]:
|
||||||
"""Create a compressed tar archive of the backup directory"""
|
"""Create a compressed tar archive of the backup directory"""
|
||||||
if verbose:
|
if verbose:
|
||||||
print("Compressing backup...")
|
print("> Compressing backup...")
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
"tar",
|
"tar",
|
||||||
@@ -256,14 +255,14 @@ class Backup:
|
|||||||
source_dir.name
|
source_dir.name
|
||||||
]
|
]
|
||||||
|
|
||||||
if verbose:
|
# if verbose:
|
||||||
cmd[1] += "-v"
|
# cmd.insert(1, "-v")
|
||||||
|
|
||||||
# capture here means suppress it/holding it
|
# capture here means suppress it/holding it
|
||||||
result = subprocess.run(cmd, capture_output=not verbose, text=None)
|
result = subprocess.run(cmd, capture_output=not verbose, text=True)
|
||||||
|
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
error_msg = f"tar failed: {result.stderr if result.stderr else 'Unknown error code'}"
|
error_msg = f"tar failed: {result.stderr if result.stderr else 'unknown error code'}"
|
||||||
|
|
||||||
return Err(error_msg)
|
return Err(error_msg)
|
||||||
|
|
||||||
@@ -272,8 +271,12 @@ class Backup:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def encrypt_file(input_file: Path, output_file: Path, password: str, verbose: bool) -> Result[None]:
|
def encrypt_file(input_file: Path, output_file: Path, password: str, verbose: bool) -> Result[None]:
|
||||||
"""Encrypt a file with GPG in symmetric mode (using AES256)"""
|
"""Encrypt a file with GPG in symmetric mode (using AES256)"""
|
||||||
|
|
||||||
|
if output_file.exists():
|
||||||
|
return Err("Encryption failed: archive already exists")
|
||||||
|
|
||||||
if verbose:
|
if verbose:
|
||||||
print("Encrypting backup...")
|
print("> Encrypting backup...", end='', flush=True)
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
"gpg", "-a",
|
"gpg", "-a",
|
||||||
@@ -296,6 +299,7 @@ class Backup:
|
|||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
return Err(f"Encryption failed: {result.stderr.decode()}")
|
return Err(f"Encryption failed: {result.stderr.decode()}")
|
||||||
|
|
||||||
|
print("DONE")
|
||||||
return Ok(None)
|
return Ok(None)
|
||||||
|
|
||||||
def make_backup(self, config: BackupState) -> Result[None]:
|
def make_backup(self, config: BackupState) -> Result[None]:
|
||||||
@@ -321,7 +325,7 @@ class Backup:
|
|||||||
# Backup each source
|
# Backup each source
|
||||||
sources_count = len(config.sources)
|
sources_count = len(config.sources)
|
||||||
for idx, source in enumerate(config.sources, 1):
|
for idx, source in enumerate(config.sources, 1):
|
||||||
print(f"Copying {source.label} ({idx}/{sources_count})")
|
print(f"> Copying {source.label} ({idx}/{sources_count})")
|
||||||
|
|
||||||
# Create source subdirectory
|
# Create source subdirectory
|
||||||
source_dir = work_dir / f"backup-{source.label}-{date_str}"
|
source_dir = work_dir / f"backup-{source.label}-{date_str}"
|
||||||
@@ -329,7 +333,7 @@ class Backup:
|
|||||||
source_dir.mkdir(parents=True, exist_ok=True)
|
source_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Copy files
|
# Copy files
|
||||||
copy_res = self.copy_files(source.path, work_dir, config.verbose)
|
copy_res = self.copy_files(source.path, source_dir)
|
||||||
match copy_res:
|
match copy_res:
|
||||||
case Err():
|
case Err():
|
||||||
self.cleanup_files(work_dir, temp_tarball)
|
self.cleanup_files(work_dir, temp_tarball)
|
||||||
@@ -343,9 +347,9 @@ class Backup:
|
|||||||
backup_progress: BackupProgress | None = None
|
backup_progress: BackupProgress | None = None
|
||||||
|
|
||||||
if config.verbose:
|
if config.verbose:
|
||||||
backup_progress = BackupProgress(len(files), "Computing checksums")
|
backup_progress = BackupProgress(len(files), "Computing checksum")
|
||||||
|
|
||||||
checksum_fd = open(checksum_file, 'a')
|
with open(checksum_file, 'a') as checksum_fd:
|
||||||
for file in files:
|
for file in files:
|
||||||
hash_result = self.compute_file_hash(file)
|
hash_result = self.compute_file_hash(file)
|
||||||
match hash_result:
|
match hash_result:
|
||||||
@@ -357,9 +361,7 @@ class Backup:
|
|||||||
checksum_fd.write(f"{v}\n")
|
checksum_fd.write(f"{v}\n")
|
||||||
|
|
||||||
if config.verbose and backup_progress is not None:
|
if config.verbose and backup_progress is not None:
|
||||||
backup_progress.update(str(file.name))
|
backup_progress.draw_progress_bar(str(file.name))
|
||||||
|
|
||||||
checksum_fd.close()
|
|
||||||
|
|
||||||
if config.verbose and backup_progress is not None:
|
if config.verbose and backup_progress is not None:
|
||||||
backup_progress.finish()
|
backup_progress.finish()
|
||||||
@@ -391,8 +393,7 @@ class Backup:
|
|||||||
file_size = backup_archive.stat().st_size
|
file_size = backup_archive.stat().st_size
|
||||||
file_size_hr = Backup.prettify_size(file_size)
|
file_size_hr = Backup.prettify_size(file_size)
|
||||||
|
|
||||||
print(f"\nBackup complete")
|
print(f"File name: '{backup_archive}'")
|
||||||
print(f"File name: {backup_archive}")
|
|
||||||
if config.checksum:
|
if config.checksum:
|
||||||
print(f"Checksum file: {checksum_file}")
|
print(f"Checksum file: {checksum_file}")
|
||||||
print(f"File size: {file_size} bytes ({file_size_hr})")
|
print(f"File size: {file_size} bytes ({file_size_hr})")
|
||||||
@@ -402,22 +403,7 @@ class Backup:
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="backup.py - modular and lightweight backup utility",
|
description="backup.py - modular and lightweight backup utility"
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
||||||
epilog="""
|
|
||||||
Usage:
|
|
||||||
> Create a backup
|
|
||||||
sudo ./backup.py --backup sources.bk /home/user $PASS
|
|
||||||
|
|
||||||
> Create backup with checksum
|
|
||||||
sudo ./backup.py --checksum --backup sources.bk /home/user $PASS
|
|
||||||
|
|
||||||
> Extract and verify checksum
|
|
||||||
./backup.py --checksum --extract backup.tar.gz.enc $PASS hashes.sha256
|
|
||||||
|
|
||||||
For more information visit: https://git.marcocetica.com/marco/backup.py
|
|
||||||
or issue `man backup.py` on your terminal.
|
|
||||||
"""
|
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -448,10 +434,15 @@ Usage:
|
|||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not (args.backup or args.extract):
|
||||||
|
parser.error("specify either --backup or --extract")
|
||||||
|
|
||||||
# Check whether dependencies are installed
|
# Check whether dependencies are installed
|
||||||
deps_res = Backup.check_deps()
|
deps_res = Backup.check_deps()
|
||||||
match deps_res:
|
match deps_res:
|
||||||
case Err(error=e): print(f"Error: {e}", file=sys.stderr)
|
case Err(error=e):
|
||||||
|
print(f"{e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
case Ok(): pass
|
case Ok(): pass
|
||||||
|
|
||||||
backup = Backup()
|
backup = Backup()
|
||||||
@@ -471,7 +462,7 @@ Usage:
|
|||||||
config: BackupState
|
config: BackupState
|
||||||
match sources_res:
|
match sources_res:
|
||||||
case Err(error=e):
|
case Err(error=e):
|
||||||
print(f"Error: {e}", file=sys.stderr)
|
print(f"{e}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
case Ok(value=v):
|
case Ok(value=v):
|
||||||
# Create a backup state
|
# Create a backup state
|
||||||
@@ -483,6 +474,11 @@ Usage:
|
|||||||
verbose=args.verbose
|
verbose=args.verbose
|
||||||
)
|
)
|
||||||
|
|
||||||
|
backup_res = backup.make_backup(config)
|
||||||
|
match backup_res:
|
||||||
|
case Err(error=e):
|
||||||
|
print(f"{e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
Reference in New Issue
Block a user