<<
path:
root/public/sod.git/html/sod.py
blob: 810da30c3f6449222716f01a1fcce7e9bf011a83
[raw]
[clear marker]
3# Copyright (c) 2026 dev@ptrace.dev
5# Permission is hereby granted, free of charge, to any person obtaining a copy
6# of this software and associated documentation files (the "Software"), to deal
7# in the Software without restriction, including without limitation the rights
8# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9# copies of the Software, and to permit persons to whom the Software is
10# furnished to do so, subject to the following conditions:
12# The above copyright notice and this permission notice shall be included in all
13# copies or substantial portions of the Software.
15# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
31from functools import wraps
33from concurrent.futures import ThreadPoolExecutor
35from argparse import ArgumentParser, RawDescriptionHelpFormatter
36from pathlib import Path
37from datetime import datetime
41PATH_HOME = Path.home()
42PATH_CONFIG = PATH_HOME / Path(".config")
43PATH_CONFIG_SOD_DIR = Path("sod-backup-helper")
44PATH_CONFIG_FILE = Path("sod.config")
45PATH_CONFIG_FILE_DOT = Path(".sod-config")
53Creates a backup from your supplied path and distributes it to specific directories.
55 Files getting archived and compressed using `tar` command.
56 Stored as `<parent dir name>_<year>_<month>_<day>_<uuid4>.tar.gz`
58 You can also change the exact pattern of the file name, like this:
59 sod -c "my_prefix_<year>_<uuid4>.tar.gz"
61 Note: It stores a config file in `~/.config/sod-backup-helper`
62 or `~/.sod-config`. It depends if the `.config` directory exists.
67HELP_P = "Adds backup paths. Example: `-p Foo Bar` or `-p Foo -p Bar`"
68HELP_L = "Lists all backup paths."
69HELP_R = "Removes the backup path by index. Index is supplied by the `-l` argument."
70HELP_N = "Removes ALL backup paths. Passing `-nn` removes the config file."
71HELP_C = "Define a new pattern. You can also use the following palceholders: <year> <month> <day> <uuid4>"
72HELP_Z = "Enable compression."
73HELP_X = "Don't create the archive, just print debug info."
74HELP_D = "Prints the current state."
77class Archive_Backend(Enum):
85 self.backup_destinations = []
86 self.name_pattern = ""
89default_state = State()
90default_state.backup_destinations = []
91default_state.name_pattern = "<name>_%Y_%m_%d_<uuid>"
96 self.parser = ArgumentParser(
97 prog = "Safe our Data",
98 formatter_class = RawDescriptionHelpFormatter,
99 description = ARGP_DESCRIPTION
101 self.parser.add_argument("path", type=Path, nargs="?", default=None, help=HELP_PATH)
102 self.parser.add_argument("-p", "--backup-path-add",
103 action="extend", nargs="+", type=str, help=HELP_P)
104 self.parser.add_argument("-l", "--backup-path-list", action="store_true", help=HELP_L)
105 self.parser.add_argument("-r", "--backup-path-remove", type=int, help=HELP_R)
106 self.parser.add_argument("-n", "--backup-path-nuke", action="count", default=0, help=HELP_N)
107 self.parser.add_argument("-c", "--pattern-change", type=str, help=HELP_C)
108 self.parser.add_argument("-z", "--archive-compress", action="store_true", help=HELP_Z)
109 self.parser.add_argument("-x", "--debug-dry-run", action="store_true", help=HELP_X)
110 self.parser.add_argument("-d", "--debug", action="store_true", help=HELP_D)
113 return self.parser.parse_args()
116 self.parser.print_help()
121args = args_handle.get()
126 def wrapper(*args, **kwargs):
127 start = time.perf_counter()
128 result = func(*args, **kwargs)
129 print(f"{func.__name__} took {time.perf_counter() - start:.6f}s")
135 config_exists, config_fp = config_find()
137 if not config_exists:
138 if not config_create_file(config_fp): return FAILURE
140 success, state = config_read(config_fp)
141 if not success: return FAILURE
144 return backup_do(state, args.path)
148 if args.backup_path_list:
149 backup_path_list(state)
150 elif cringe(args.pattern_change):
151 backup_name_fmt_new(state, args.pattern_change)
153 debug_view(state, config_fp)
154 elif cringe(args.backup_path_add):
155 operation = backup_path_add(state, args.backup_path_add)
156 elif cringe(args.backup_path_remove):
157 operation = backup_path_remove(state, args.backup_path_remove)
158 elif args.backup_path_nuke == 1:
159 backup_path_nuke(state)
160 elif args.backup_path_nuke == 2:
161 return config_delete(config_fp)
163 if operation == SUCCESS:
164 config_write(state, config_fp)
169def cringe(anything) -> bool:
170 return anything is not None
173def path_process_and_validate(state: State, path: Path) -> tuple[bool, Path, Path]:
174 if not path.exists():
175 print("This path does not exist:", path)
176 return FAILURE, Path(), Path()
179 archive_name = backup_name_create(state, stem)
181 return SUCCESS, archive_name, path
185def archive_make(archive_fp, file_to_archive) -> bool:
186 backend = archive_backend_determine()
188 if args.debug_dry_run:
189 dbg("-- Archiving -------------")
190 dbg(f"archive_fp: {archive_fp}")
191 dbg(f"file_to_archive: {file_to_archive}")
192 dbg(f"backend: {backend}")
193 dbg(f"compression: {args.archive_compress}")
197 if backend == Archive_Backend.GNU:
198 archive_backend_gnu(archive_fp, file_to_archive)
199 elif backend == Archive_Backend.PYTHON:
200 archive_backend_python(archive_fp, file_to_archive)
202 print("This should not happen! Blame the developer!")
205 except Exception as e:
206 print("Could not archive file:", e)
210def archive_backend_python(archive_fp, file_to_archive):
211 compression = "w:gz" if args.archive_compress else "w:"
212 with tarfile.open(archive_fp, compression) as tar:
213 tar.add(file_to_archive)
216def archive_backend_gnu(archive_fp, file_to_archive):
217 compression = "-czf" if args.archive_compress else "-cf"
218 cmd = ["tar", compression, archive_fp, file_to_archive]
219 proc = subprocess.run(cmd, capture_output=True)
221 # fugly check, I know
222 if proc.returncode != 0:
226 proc.check_returncode()
229def archive_backend_determine() -> Archive_Backend:
230 probe_tar_cmd = ["tar", "--version"]
231 s = subprocess.run(probe_tar_cmd, capture_output=True)
233 if s.returncode == 0:
234 return Archive_Backend.GNU
236 return Archive_Backend.PYTHON
239def make_directory_if_not_exists(fp: Path) -> bool:
240 if args.debug_dry_run:
244 fp.mkdir(exist_ok=True)
246 except Exception as e:
247 print("Could not create directory:", e)
251def backup_do(state: State, fp) -> bool:
252 if len(state.backup_destinations) == 0:
253 print("No backup path defined. Canceling ...")
256 operation, archive_name, unarchived_file_path = path_process_and_validate(state, fp)
257 if not operation: return operation
259 first_backup_dir = state.backup_destinations[0]
261 intermediate_path = Path(unarchived_file_path.stem)
262 archive_dir = first_backup_dir / intermediate_path
263 archive_fp = archive_dir / archive_name
265 if not make_directory_if_not_exists(archive_dir): return FAILURE
266 if not archive_make(archive_fp, unarchived_file_path): return FAILURE
267 if not backup_distribute(state, archive_name, intermediate_path): return FAILURE
269 print("Backups archived and distributed.")
274def backup_distribute(state: State, archive_name, intermediate_path) -> bool:
276 def copy_file(src, dst):
277 shutil.copy(src, dst)
279 def distribute(src, destinations, max_workers=5):
280 with ThreadPoolExecutor(max_workers=max_workers) as executor:
281 futures = [executor.submit(copy_file, src, dst) for dst in destinations]
282 for future in futures:
286 source_file = state.backup_destinations[0] / intermediate_path / archive_name
288 if not args.debug_dry_run and not source_file.exists():
289 print("[ Error ]: Sanity check failed, could not find:", source_file)
292 valid_backup_paths = []
294 for i in range(1, len(state.backup_destinations)):
295 path = state.backup_destinations[i] / intermediate_path
297 if not make_directory_if_not_exists(path):
298 print("[ Warning ]: Skipping current operation.")
301 valid_backup_paths.append(path / archive_name)
303 if args.debug_dry_run:
304 dbg("-- Distribute ------------")
305 dbg(f"source_file: {source_file}")
306 dbg("valid_backup_paths:")
307 for dbg_paths in valid_backup_paths:
312 distribute(source_file, valid_backup_paths)
313 except Exception as e:
314 print("Could not copy backups to other destinations:", e)
321def backup_name_create(state: State, fn) -> Path:
322 as_date = datetime.now().strftime(state.name_pattern)
323 out = (as_date.replace("<name>", fn)
324 .replace("<uuid>", str(uuid4())))
325 file_ext = "tar.gz" if args.archive_compress else "tar"
326 return Path(f"{out}.{file_ext}")
329def backup_name_fmt_new(state: State, fmt):
330 state.name_pattern = fmt
331 print("Created new pattern:", state.name_pattern)
334def backup_path_nuke(state: State):
335 if len(state.backup_destinations) == 0:
336 print("No paths in list")
339 safe_phrase = "I'm damn sure!"
340 print("Are you sure to remove every path? " \
341 f"Type `{safe_phrase}` to confirm it, or nothing to cancel:")
345 if user == safe_phrase:
346 state.backup_destinations = []
347 print("Removed all paths.")
350 print("Operation canceled.")
353def backup_path_remove(state: State, path_index):
354 length = len(state.backup_destinations)
357 print("No paths in list")
360 if path_index < 0 or path_index > length-1 :
361 print("Invalid index. Can't be lower than zero or higher than", length-1)
364 removed = state.backup_destinations.pop(path_index)
365 print("Removed:", removed)
369def backup_path_add(state: State, paths):
371 print("No paths supplied")
374 d = state.backup_destinations
377 p = Path(path).resolve()
379 print("[ Warning ]: Skipped this path, since it does not exist:", p)
381 if not p in d: d.append(p)
383 print("\nCurrent backup paths:")
384 backup_path_list(state)
389def backup_path_list(state: State):
390 if not state.backup_destinations:
391 print("No backup paths defined yet")
393 for idx, path in enumerate(state.backup_destinations):
394 print(f" {idx}:", path)
399def config_find() -> tuple[bool, Path]:
400 path_config_at_home = PATH_HOME / PATH_CONFIG_FILE_DOT
401 path_config_at_dotconfig = PATH_CONFIG / PATH_CONFIG_SOD_DIR / PATH_CONFIG_FILE
403 if PATH_CONFIG.exists():
404 if path_config_at_dotconfig.exists():
405 return SUCCESS, path_config_at_dotconfig
407 return FAILURE, path_config_at_dotconfig
409 if path_config_at_home:
410 return SUCCESS, path_config_at_home
412 return FAILURE, path_config_at_home
415def config_create_file(config_fp: Path) -> bool:
417 config_fp.parent.mkdir(exist_ok=True)
418 success = config_write(default_state, config_fp)
420 if not success: return FAILURE
422 print(f"Created configuration file at `{config_fp}`")
424 except Exception as e:
425 print("Error: Could not create file:", e)
429def config_delete(config_fp: Path) -> bool:
430 safe_phrase = "YEEES!"
432 print("Are you sure you want to delete the configuration file?")
433 print(f"Type `{safe_phrase}` to delete, type nothing to abort:")
437 if user == safe_phrase:
440 print("Deleted:", config_fp)
442 except Exception as e:
443 print("Could not delete file:", e)
446 print("Operation canceled.")
450def config_write(state: State, config_fp) -> bool:
452 with open(config_fp, 'wb') as f:
453 pickle.dump(state, f)
455 except Exception as e:
456 print("Error: Could not write config file:", e)
460def config_read(config_fp) -> tuple[bool, State]:
462 with open(config_fp, 'rb') as f:
463 return SUCCESS, pickle.load(f)
464 except Exception as e:
465 print("Error: Could not read config file:", e)
466 return FAILURE, State()
469def debug_view(state: State, config_fp):
470 print("Config Path:", config_fp)
471 debug_display_state_file("State File ", state)
472 debug_display_state_file("Default Config ", default_state)
475def debug_display_state_file(label, state: State):
476 print(f"-- Debug: {label:-<40}")
479 for k, v in state.__dict__.items():
480 if isinstance(v, list):
481 print(f"{' '*4}{k}:")
482 for e in v: print(f"{' '*8}{e}")
485 print(f"{' '*4}{k}: {v}")
491 print("[ DEBUG ]:", msg)
495if __name__ == "__main__":
496 if len(sys.argv) == 1: