Author:ptrace Comitter:ptrace Date:2026-01-13 23:25:32 UTC

initial

diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..63e85fc --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ MIT License Copyright (c) 2026 dev@ptrace.dev Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8b54fa7 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ # SOD - Save our Data Small backup tool that archives, compresses and distributes the backups to different  locations. ## Usage ### Config If you run it the first time, you have to add backup paths, where the backups should be  distributed to:   `./sod.py -p /my_drives/my_drive_A/backups /my_drives/my_drive_B/backups`   You can list the configured backups paths with:   `./sod.py -l`   ### Backup Uncompressed backup of a directory (or file):   `./sod.py my_important_directory`   With compression:   `./sod.py -z my_important_directory`   ### Help Via `./sod.py -h` or ` --help` diff --git a/sod.py b/sod.py new file mode 100755 index 0000000..810da30 --- /dev/null +++ b/sod.py @@ -0,0 +1,504 @@ #!/bin/python # MIT License # # Copyright (c) 2026 dev@ptrace.dev # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. # import pickle import sys import os import tarfile import shutil import subprocess import time from functools import wraps from enum import Enum from concurrent.futures import ThreadPoolExecutor from uuid import uuid4 from argparse import ArgumentParser, RawDescriptionHelpFormatter from pathlib import Path from datetime import datetime PATH_HOME = Path.home() PATH_CONFIG = PATH_HOME / Path(".config") PATH_CONFIG_SOD_DIR = Path("sod-backup-helper") PATH_CONFIG_FILE = Path("sod.config") PATH_CONFIG_FILE_DOT = Path(".sod-config") SUCCESS = True FAILURE = False ARGP_DESCRIPTION = """ Creates a backup from your supplied path and distributes it to specific directories.     Files getting archived and compressed using `tar` command.     Stored as `<parent dir name>_<year>_<month>_<day>_<uuid4>.tar.gz`     You can also change the exact pattern of the file name, like this:         sod -c "my_prefix_<year>_<uuid4>.tar.gz"     Note: It stores a config file in `~/.config/sod-backup-helper`           or `~/.sod-config`. It depends if the `.config` directory exists. """ HELP_PATH = "" HELP_P = "Adds backup paths. Example: `-p Foo Bar` or `-p Foo -p Bar`" HELP_L = "Lists all backup paths." HELP_R = "Removes the backup path by index. Index is supplied by the `-l` argument." HELP_N = "Removes ALL backup paths. Passing `-nn` removes the config file." HELP_C = "Define a new pattern. You can also use the following palceholders: <year> <month> <day> <uuid4>" HELP_Z = "Enable compression." HELP_X = "Don't create the archive, just print debug info." HELP_D = "Prints the current state." class Archive_Backend(Enum):     NONE    = 0     PYTHON  = 1     GNU     = 2 class State:     def __init__(self):         self.backup_destinations = []         self.name_pattern = "" default_state = State() default_state.backup_destinations = [] default_state.name_pattern = "<name>_%Y_%m_%d_<uuid>" class Args:     def __init__(self):         self.parser = ArgumentParser(             prog = "Safe our Data",             formatter_class = RawDescriptionHelpFormatter,             description = ARGP_DESCRIPTION         )         self.parser.add_argument("path", type=Path, nargs="?", default=None, help=HELP_PATH)         self.parser.add_argument("-p", "--backup-path-add",                                  action="extend", nargs="+", type=str, help=HELP_P)         self.parser.add_argument("-l", "--backup-path-list", action="store_true", help=HELP_L)         self.parser.add_argument("-r", "--backup-path-remove", type=int, help=HELP_R)         self.parser.add_argument("-n", "--backup-path-nuke", action="count", default=0, help=HELP_N)         self.parser.add_argument("-c", "--pattern-change", type=str, help=HELP_C)         self.parser.add_argument("-z", "--archive-compress", action="store_true", help=HELP_Z)         self.parser.add_argument("-x", "--debug-dry-run", action="store_true", help=HELP_X)         self.parser.add_argument("-d", "--debug", action="store_true", help=HELP_D)     def get(self):         return self.parser.parse_args()     def help(self):         self.parser.print_help()         return self args_handle = Args() args = args_handle.get() def timer(func):     @wraps(func)     def wrapper(*args, **kwargs):         start = time.perf_counter()         result = func(*args, **kwargs)         print(f"{func.__name__} took {time.perf_counter() - start:.6f}s")         return result     return wrapper def run():     config_exists, config_fp = config_find()     if not config_exists:         if not config_create_file(config_fp): return FAILURE     success, state = config_read(config_fp)     if not success: return FAILURE     if args.path:         return backup_do(state, args.path)     operation = SUCCESS     if args.backup_path_list:         backup_path_list(state)     elif cringe(args.pattern_change):         backup_name_fmt_new(state, args.pattern_change)     elif args.debug:         debug_view(state, config_fp)     elif cringe(args.backup_path_add):         operation = backup_path_add(state, args.backup_path_add)     elif cringe(args.backup_path_remove):         operation = backup_path_remove(state, args.backup_path_remove)     elif args.backup_path_nuke == 1:         backup_path_nuke(state)     elif args.backup_path_nuke == 2:         return config_delete(config_fp)     if operation == SUCCESS:         config_write(state, config_fp)     return operation def cringe(anything) -> bool:     return anything is not None def path_process_and_validate(state: State, path: Path) -> tuple[bool, Path, Path]:     if not path.exists():         print("This path does not exist:", path)         return FAILURE, Path(), Path()     stem = path.stem     archive_name = backup_name_create(state, stem)     return SUCCESS, archive_name, path @timer def archive_make(archive_fp, file_to_archive) -> bool:     backend = archive_backend_determine()     if args.debug_dry_run:         dbg("-- Archiving -------------")         dbg(f"archive_fp: {archive_fp}")         dbg(f"file_to_archive: {file_to_archive}")         dbg(f"backend: {backend}")         dbg(f"compression: {args.archive_compress}")         return SUCCESS     try:         if backend == Archive_Backend.GNU:             archive_backend_gnu(archive_fp, file_to_archive)         elif backend == Archive_Backend.PYTHON:             archive_backend_python(archive_fp, file_to_archive)         else:             print("This should not happen! Blame the developer!")             return FAILURE         return SUCCESS     except Exception as e:         print("Could not archive file:", e)         return FAILURE def archive_backend_python(archive_fp, file_to_archive):     compression = "w:gz" if args.archive_compress else "w:"     with tarfile.open(archive_fp, compression) as tar:         tar.add(file_to_archive) def archive_backend_gnu(archive_fp, file_to_archive):     compression = "-czf" if args.archive_compress else "-cf"     cmd = ["tar", compression, archive_fp, file_to_archive]     proc = subprocess.run(cmd, capture_output=True)     # fugly check, I know     if proc.returncode != 0:         print(proc.stderr)         print(proc.stdout)     proc.check_returncode() def archive_backend_determine() -> Archive_Backend:     probe_tar_cmd = ["tar", "--version"]     s = subprocess.run(probe_tar_cmd, capture_output=True)     if s.returncode == 0:         return Archive_Backend.GNU     else:         return Archive_Backend.PYTHON def make_directory_if_not_exists(fp: Path) -> bool:     if args.debug_dry_run:         dbg(f"mkdir: {fp}")         return SUCCESS     try:         fp.mkdir(exist_ok=True)         return SUCCESS     except Exception as e:         print("Could not create directory:", e)         return FAILURE def backup_do(state: State, fp) -> bool:     if len(state.backup_destinations) == 0:         print("No backup path defined. Canceling ...")         return FAILURE     operation, archive_name, unarchived_file_path = path_process_and_validate(state, fp)     if not operation: return operation     first_backup_dir = state.backup_destinations[0]     intermediate_path = Path(unarchived_file_path.stem)     archive_dir = first_backup_dir / intermediate_path     archive_fp = archive_dir / archive_name     if not make_directory_if_not_exists(archive_dir): return FAILURE     if not archive_make(archive_fp, unarchived_file_path): return FAILURE     if not backup_distribute(state, archive_name, intermediate_path): return FAILURE     print("Backups archived and distributed.")     return SUCCESS @timer def backup_distribute(state: State, archive_name, intermediate_path) -> bool:     def copy_file(src, dst):         shutil.copy(src, dst)     def distribute(src, destinations, max_workers=5):         with ThreadPoolExecutor(max_workers=max_workers) as executor:             futures = [executor.submit(copy_file, src, dst) for dst in destinations]             for future in futures:                 future.result()     source_file = state.backup_destinations[0] / intermediate_path / archive_name     if not args.debug_dry_run and not source_file.exists():         print("[  Error  ]: Sanity check failed, could not find:", source_file)         return FAILURE     valid_backup_paths = []     for i in range(1, len(state.backup_destinations)):         path = state.backup_destinations[i] / intermediate_path         if not make_directory_if_not_exists(path):             print("[  Warning  ]: Skipping current operation.")             continue         valid_backup_paths.append(path / archive_name)     if args.debug_dry_run:         dbg("-- Distribute ------------")         dbg(f"source_file: {source_file}")         dbg("valid_backup_paths:")         for dbg_paths in valid_backup_paths:             dbg(f"    {dbg_paths}")         return SUCCESS     try:         distribute(source_file, valid_backup_paths)     except Exception as e:         print("Could not copy backups to other destinations:", e)         return FAILURE     return SUCCESS def backup_name_create(state: State, fn) -> Path:     as_date = datetime.now().strftime(state.name_pattern)     out = (as_date.replace("<name>", fn)                   .replace("<uuid>", str(uuid4())))     file_ext = "tar.gz" if args.archive_compress else "tar"     return Path(f"{out}.{file_ext}") def backup_name_fmt_new(state: State, fmt):     state.name_pattern = fmt     print("Created new pattern:", state.name_pattern) def backup_path_nuke(state: State):     if len(state.backup_destinations) == 0:         print("No paths in list")         return     safe_phrase = "I'm damn sure!"     print("Are you sure to remove every path? " \          f"Type `{safe_phrase}` to confirm it, or nothing to cancel:")     user = input("> ")     if user == safe_phrase:         state.backup_destinations = []         print("Removed all paths.")         return     print("Operation canceled.") def backup_path_remove(state: State, path_index):     length = len(state.backup_destinations)     if length == 0:         print("No paths in list")         return SUCCESS     if path_index < 0 or path_index > length-1 :         print("Invalid index. Can't be lower than zero or higher than", length-1)         return FAILURE     removed = state.backup_destinations.pop(path_index)     print("Removed:", removed)     return SUCCESS def backup_path_add(state: State, paths):     if not paths:         print("No paths supplied")         return FAILURE     d = state.backup_destinations     for path in paths:         p = Path(path).resolve()         if not p.exists():             print("[  Warning  ]: Skipped this path, since it does not exist:", p)             continue         if not p in d: d.append(p)     print("\nCurrent backup paths:")     backup_path_list(state)     return SUCCESS def backup_path_list(state: State):     if not state.backup_destinations:         print("No backup paths defined yet")     for idx, path in enumerate(state.backup_destinations):         print(f"  {idx}:", path)     print() def config_find() -> tuple[bool, Path]:     path_config_at_home = PATH_HOME / PATH_CONFIG_FILE_DOT     path_config_at_dotconfig = PATH_CONFIG / PATH_CONFIG_SOD_DIR / PATH_CONFIG_FILE     if PATH_CONFIG.exists():         if path_config_at_dotconfig.exists():             return SUCCESS, path_config_at_dotconfig         else:             return FAILURE, path_config_at_dotconfig     if path_config_at_home:         return SUCCESS, path_config_at_home     return FAILURE, path_config_at_home def config_create_file(config_fp: Path) -> bool:     try:         config_fp.parent.mkdir(exist_ok=True)         success = config_write(default_state, config_fp)         if not success: return FAILURE         print(f"Created configuration file at `{config_fp}`")         return SUCCESS     except Exception as e:         print("Error: Could not create file:", e)         return FAILURE def config_delete(config_fp: Path) -> bool:     safe_phrase = "YEEES!"     print("Are you sure you want to delete the configuration file?")     print(f"Type `{safe_phrase}` to delete, type nothing to abort:")     user = input("> ")     if user == safe_phrase:         try:             os.remove(config_fp)             print("Deleted:", config_fp)             return SUCCESS         except Exception as e:             print("Could not delete file:", e)             return FAILURE     print("Operation canceled.")     return SUCCESS def config_write(state: State, config_fp) -> bool:     try:         with open(config_fp, 'wb') as f:             pickle.dump(state, f)         return SUCCESS     except Exception as e:         print("Error: Could not write config file:", e)         return FAILURE def config_read(config_fp) -> tuple[bool, State]:     try:         with open(config_fp, 'rb') as f:             return SUCCESS, pickle.load(f)     except Exception as e:         print("Error: Could not read config file:", e)         return FAILURE, State() def debug_view(state: State, config_fp):     print("Config Path:", config_fp)     debug_display_state_file("State File ", state)     debug_display_state_file("Default Config ", default_state) def debug_display_state_file(label, state: State):     print(f"-- Debug: {label:-<40}")     print()     for k, v in state.__dict__.items():         if isinstance(v, list):             print(f"{' '*4}{k}:")             for e in v: print(f"{' '*8}{e}")             print()         else:             print(f"{' '*4}{k}:  {v}")     print() def dbg(msg):     print("[  DEBUG   ]:", msg) if __name__ == "__main__":     if len(sys.argv) == 1:         args_handle.help()         sys.exit(0)     if run():         sys.exit(0)     else:         sys.exit(-1)