Source code for stouputils.backup.create


# Imports
import datetime
import fnmatch
import os
import zipfile

from ..config import StouputilsConfig as Cfg
from ..decorators import handle_error, measure_time
from ..io.path import clean_path
from ..print.message import info, warning
from .hash import get_file_hash
from .retrieve import get_all_previous_backups, is_file_in_any_previous_backup


# Main backup function that creates a delta backup (only changed files)
[docs] @measure_time(message="Creating ZIP backup") @handle_error def create_delta_backup(source_path: str, destination_folder: str, exclude_patterns: list[str] | None = None) -> None: """ Creates a ZIP delta backup, saving only modified or new files while tracking deleted files. Args: source_path (str): Path to the source file or directory to back up destination_folder (str): Path to the folder where the backup will be saved exclude_patterns (list[str] | None): List of glob patterns to exclude from backup Examples: .. code-block:: python > create_delta_backup("/path/to/source", "/path/to/backups", exclude_patterns=["libraries/*", "cache/*"]) [INFO HH:MM:SS] Creating ZIP backup [INFO HH:MM:SS] Backup created: '/path/to/backups/backup_2025_02_18-10_00_00.zip' """ source_path = clean_path(os.path.abspath(source_path)) destination_folder = clean_path(os.path.abspath(destination_folder)) # Setup backup paths and create destination folder base_name: str = os.path.basename(source_path.rstrip(os.sep)) or "backup" backup_folder: str = clean_path(os.path.join(destination_folder, base_name)) os.makedirs(backup_folder, exist_ok=True) # Get previous backups and track all files previous_backups: dict[str, dict[str, str]] = get_all_previous_backups(backup_folder) previous_files: set[str] = {file for backup in previous_backups.values() for file in backup} # Collect all tracked files # Create new backup filename with timestamp timestamp: str = datetime.datetime.now().strftime("%Y_%m_%d-%H_%M_%S") zip_filename: str = f"{timestamp}.zip" destination_zip: str = clean_path(os.path.join(backup_folder, zip_filename)) # Create the ZIP file early to write files as we process them with zipfile.ZipFile(destination_zip, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as zipf: deleted_files: set[str] = set() has_changes: bool = False # Process files one by one to avoid memory issues if os.path.isdir(source_path): for root, _, files in os.walk(source_path): for file in files: full_path: str = clean_path(os.path.join(root, file)) arcname: str = clean_path(os.path.relpath(full_path, start=os.path.dirname(source_path))) # Skip file if it matches any exclude pattern if exclude_patterns and any(fnmatch.fnmatch(arcname, pattern) for pattern in exclude_patterns): continue file_hash: str | None = get_file_hash(full_path) if file_hash is None: continue # Check if file needs to be backed up if not is_file_in_any_previous_backup(arcname, file_hash, previous_backups): try: zip_info: zipfile.ZipInfo = zipfile.ZipInfo(arcname) zip_info.compress_type = zipfile.ZIP_DEFLATED zip_info.comment = file_hash.encode() # Store hash in comment # Read and write file in chunks with larger buffer with open(full_path, "rb") as f: with zipf.open(zip_info, "w", force_zip64=True) as zf: while True: chunk = f.read(Cfg.CHUNK_SIZE) if not chunk: break zf.write(chunk) has_changes = True except Exception as e: warning(f"Error writing file {full_path} to backup: {e}") # Track current files for deletion detection if arcname in previous_files: previous_files.remove(arcname) else: arcname: str = clean_path(os.path.basename(source_path)) file_hash: str | None = get_file_hash(source_path) if file_hash is not None and not is_file_in_any_previous_backup(arcname, file_hash, previous_backups): try: zip_info: zipfile.ZipInfo = zipfile.ZipInfo(arcname) zip_info.compress_type = zipfile.ZIP_DEFLATED zip_info.comment = file_hash.encode() with open(source_path, "rb") as f: with zipf.open(zip_info, "w", force_zip64=True) as zf: while True: chunk = f.read(Cfg.CHUNK_SIZE) if not chunk: break zf.write(chunk) has_changes = True except Exception as e: warning(f"Error writing file {source_path} to backup: {e}") # Any remaining files in previous_files were deleted deleted_files = previous_files if deleted_files: zipf.writestr("__deleted_files__.txt", "\n".join(deleted_files), compress_type=zipfile.ZIP_DEFLATED) has_changes = True # Remove empty backup if no changes if not has_changes: os.remove(destination_zip) info(f"No files to backup, skipping creation of backup '{destination_zip}'") else: info(f"Backup created: '{destination_zip}'")