dar-backup 1.0.0__py3-none-any.whl → 1.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
dar_backup/dar_backup.py CHANGED
@@ -12,9 +12,6 @@ See section 15 and section 16 in the supplied "LICENSE" file
12
12
 
13
13
  This script can be used to control `dar` to backup parts of or the whole system.
14
14
  """
15
-
16
-
17
-
18
15
  import argcomplete
19
16
  import argparse
20
17
  import filecmp
@@ -25,9 +22,9 @@ import re
25
22
  import shlex
26
23
  import shutil
27
24
  import subprocess
25
+ import configparser
28
26
  import xml.etree.ElementTree as ET
29
27
  import tempfile
30
- import threading
31
28
 
32
29
  from argparse import ArgumentParser
33
30
  from datetime import datetime
@@ -39,7 +36,6 @@ from sys import version_info
39
36
  from time import time
40
37
  from rich.console import Console
41
38
  from rich.text import Text
42
- from threading import Event
43
39
  from typing import List, Tuple
44
40
 
45
41
  from . import __about__ as about
@@ -51,17 +47,18 @@ from dar_backup.util import BackupError
51
47
  from dar_backup.util import RestoreError
52
48
  from dar_backup.util import requirements
53
49
  from dar_backup.util import show_version
50
+ from dar_backup.util import get_config_file
54
51
  from dar_backup.util import get_invocation_command_line
55
52
  from dar_backup.util import get_binary_info
56
53
  from dar_backup.util import print_aligned_settings
57
54
  from dar_backup.util import backup_definition_completer, list_archive_completer
58
55
  from dar_backup.util import show_scriptname
59
56
  from dar_backup.util import print_debug
57
+ from dar_backup.util import send_discord_message
60
58
 
61
59
  from dar_backup.command_runner import CommandRunner
62
60
  from dar_backup.command_runner import CommandResult
63
61
 
64
- from dar_backup.rich_progress import show_log_driven_bar
65
62
 
66
63
  from argcomplete.completers import FilesCompleter
67
64
 
@@ -99,29 +96,11 @@ def generic_backup(type: str, command: List[str], backup_file: str, backup_defin
99
96
 
100
97
  logger.info(f"===> Starting {type} backup for {backup_definition}")
101
98
  try:
102
- log_basename = os.path. dirname(config_settings.logfile_location)
103
- logfile = os.path.basename(config_settings.logfile_location)[:-4] + "-commands.log"
104
- log_path = os.path.join( log_basename, logfile)
105
- logger.debug(f"Commands log file: {log_path}")
106
-
107
- # wrap a progress bar around the dar command
108
- stop_event = Event()
109
- session_marker = f"=== START BACKUP SESSION: {int(time())} ==="
110
- get_logger(command_output_logger=True).info(session_marker)
111
- progress_thread = threading.Thread(
112
- target=show_log_driven_bar,
113
- args=(log_path, stop_event, session_marker),
114
- daemon=True
115
- )
116
- progress_thread.start()
117
99
  try:
118
- process = runner.run(command, timeout = config_settings.command_timeout_secs)
100
+ process = runner.run(command, timeout=config_settings.command_timeout_secs)
119
101
  except Exception as e:
120
102
  print(f"[!] Backup failed: {e}")
121
103
  raise
122
- finally:
123
- stop_event.set()
124
- progress_thread.join()
125
104
 
126
105
  if process.returncode == 0:
127
106
  logger.info(f"{type} backup completed successfully.")
@@ -227,6 +206,39 @@ def find_files_between_min_and_max_size(backed_up_files: list[(str, str)], confi
227
206
  return files
228
207
 
229
208
 
209
+ def filter_restoretest_candidates(files: List[str], config_settings: ConfigSettings) -> List[str]:
210
+ prefixes = [
211
+ prefix.lstrip("/").lower()
212
+ for prefix in getattr(config_settings, "restoretest_exclude_prefixes", [])
213
+ ]
214
+ suffixes = [
215
+ suffix.lower()
216
+ for suffix in getattr(config_settings, "restoretest_exclude_suffixes", [])
217
+ ]
218
+ regex = getattr(config_settings, "restoretest_exclude_regex", None)
219
+
220
+ if not prefixes and not suffixes and not regex:
221
+ return files
222
+
223
+ filtered = []
224
+ for path in files:
225
+ normalized = path.lstrip("/")
226
+ lowered = normalized.lower()
227
+ if prefixes and any(lowered.startswith(prefix) for prefix in prefixes):
228
+ continue
229
+ if suffixes and any(lowered.endswith(suffix) for suffix in suffixes):
230
+ continue
231
+ if regex and regex.search(normalized):
232
+ continue
233
+ filtered.append(path)
234
+
235
+ if logger:
236
+ excluded = len(files) - len(filtered)
237
+ if excluded:
238
+ logger.debug(f"Restore test filter excluded {excluded} of {len(files)} candidates")
239
+ return filtered
240
+
241
+
230
242
  def verify(args: argparse.Namespace, backup_file: str, backup_definition: str, config_settings: ConfigSettings):
231
243
  """
232
244
  Verify the integrity of a DAR backup by performing the following steps:
@@ -251,29 +263,11 @@ def verify(args: argparse.Namespace, backup_file: str, backup_definition: str, c
251
263
  command = ['dar', '-t', backup_file, '-N', '-Q']
252
264
 
253
265
 
254
- log_basename = os.path. dirname(config_settings.logfile_location)
255
- logfile = os.path.basename(config_settings.logfile_location)[:-4] + "-commands.log"
256
- log_path = os.path.join( log_basename, logfile)
257
-
258
- # wrap a progress bar around the dar command
259
- stop_event = Event()
260
- session_marker = f"=== START BACKUP SESSION: {int(time())} ==="
261
- get_logger(command_output_logger=True).info(session_marker)
262
-
263
- progress_thread = threading.Thread(
264
- target=show_log_driven_bar,
265
- args=(log_path, stop_event, session_marker),
266
- daemon=True
267
- )
268
- progress_thread.start()
269
266
  try:
270
- process = runner.run(command, timeout = config_settings.command_timeout_secs)
267
+ process = runner.run(command, timeout=config_settings.command_timeout_secs)
271
268
  except Exception as e:
272
269
  print(f"[!] Backup failed: {e}")
273
270
  raise
274
- finally:
275
- stop_event.set()
276
- progress_thread.join()
277
271
 
278
272
 
279
273
  if process.returncode == 0:
@@ -287,8 +281,11 @@ def verify(args: argparse.Namespace, backup_file: str, backup_definition: str, c
287
281
  backed_up_files = get_backed_up_files(backup_file, config_settings.backup_dir)
288
282
 
289
283
  files = find_files_between_min_and_max_size(backed_up_files, config_settings)
284
+ files = filter_restoretest_candidates(files, config_settings)
290
285
  if len(files) == 0:
291
- logger.info(f"No files between {config_settings.min_size_verification_mb}MB and {config_settings.max_size_verification_mb}MB for verification, skipping")
286
+ logger.info(
287
+ "No files eligible for verification after size and restore-test filters, skipping"
288
+ )
292
289
  return result
293
290
 
294
291
  # find Root path in backup definition
@@ -312,6 +309,13 @@ def verify(args: argparse.Namespace, backup_file: str, backup_definition: str, c
312
309
  if len(files) < config_settings.no_files_verification:
313
310
  no_files_verification = len(files)
314
311
  random_files = random.sample(files, no_files_verification)
312
+
313
+ # Ensure restore directory exists for verification restores
314
+ try:
315
+ os.makedirs(config_settings.test_restore_dir, exist_ok=True)
316
+ except OSError as exc:
317
+ raise BackupError(f"Cannot create restore directory '{config_settings.test_restore_dir}': {exc}") from exc
318
+
315
319
  for restored_file_path in random_files:
316
320
  try:
317
321
  args.verbose and logger.info(f"Restoring file: '{restored_file_path}' from backup to: '{config_settings.test_restore_dir}' for file comparing")
@@ -465,6 +469,100 @@ def create_backup_command(backup_type: str, backup_file: str, darrc: str, backup
465
469
  return base_command
466
470
 
467
471
 
472
+ def validate_required_directories(config_settings: ConfigSettings) -> None:
473
+ """
474
+ Ensure configured directories exist; raise if any are missing.
475
+ """
476
+ required = [
477
+ ("BACKUP_DIR", config_settings.backup_dir),
478
+ ("BACKUP.D_DIR", config_settings.backup_d_dir),
479
+ ("TEST_RESTORE_DIR", config_settings.test_restore_dir),
480
+ ]
481
+ manager_db_dir = getattr(config_settings, "manager_db_dir", None)
482
+ if manager_db_dir:
483
+ required.append(("MANAGER_DB_DIR", manager_db_dir))
484
+
485
+ missing = [(name, path) for name, path in required if not path or not os.path.isdir(path)]
486
+ if missing:
487
+ details = "; ".join(f"{name}={path}" for name, path in missing)
488
+ raise RuntimeError(f"Required directories missing or not accessible: {details}")
489
+
490
+
491
+ def preflight_check(args: argparse.Namespace, config_settings: ConfigSettings) -> bool:
492
+ """
493
+ Run preflight checks to validate environment before backup.
494
+ """
495
+ errors = []
496
+
497
+ def check_dir(name: str, path: str, require_write: bool = True):
498
+ if not path:
499
+ errors.append(f"{name} is not set")
500
+ return
501
+ if not os.path.isdir(path):
502
+ errors.append(f"{name} does not exist: {path}")
503
+ return
504
+ if require_write and not os.access(path, os.W_OK):
505
+ errors.append(f"{name} is not writable: {path}")
506
+
507
+ # Directories and permissions
508
+ check_dir("BACKUP_DIR", config_settings.backup_dir)
509
+ check_dir("BACKUP.D_DIR", config_settings.backup_d_dir)
510
+ check_dir("TEST_RESTORE_DIR", config_settings.test_restore_dir)
511
+ if getattr(config_settings, "manager_db_dir", None):
512
+ check_dir("MANAGER_DB_DIR", config_settings.manager_db_dir)
513
+
514
+ # Log directory write access
515
+ log_dir = os.path.dirname(config_settings.logfile_location)
516
+ check_dir("LOGFILE_LOCATION directory", log_dir)
517
+
518
+ # Binaries present
519
+ for cmd in ("dar",):
520
+ if shutil.which(cmd) is None:
521
+ errors.append(f"Binary not found on PATH: {cmd}")
522
+ if getattr(config_settings, "par2_enabled", False):
523
+ if shutil.which("par2") is None:
524
+ errors.append("Binary not found on PATH: par2 (required when PAR2.ENABLED is true)")
525
+
526
+ # Binaries respond to --version (basic health)
527
+ for cmd in ("dar",):
528
+ if shutil.which(cmd):
529
+ try:
530
+ subprocess.run([cmd, "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
531
+ except Exception:
532
+ errors.append(f"Failed to run '{cmd} --version'")
533
+ if getattr(config_settings, "par2_enabled", False) and shutil.which("par2"):
534
+ try:
535
+ subprocess.run(["par2", "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
536
+ except Exception:
537
+ errors.append("Failed to run 'par2 --version'")
538
+
539
+ # Restore scratch: can create/clean temp file
540
+ scratch_test_file = os.path.join(config_settings.test_restore_dir, ".dar-backup-preflight")
541
+ try:
542
+ os.makedirs(config_settings.test_restore_dir, exist_ok=True)
543
+ with open(scratch_test_file, "w") as f:
544
+ f.write("ok")
545
+ os.remove(scratch_test_file)
546
+ except Exception as exc:
547
+ errors.append(f"Cannot write to TEST_RESTORE_DIR ({config_settings.test_restore_dir}): {exc}")
548
+
549
+ # Config sanity: backup definition exists if provided
550
+ if args.backup_definition:
551
+ candidate = os.path.join(config_settings.backup_d_dir, args.backup_definition)
552
+ if not os.path.isfile(candidate):
553
+ errors.append(f"Backup definition not found: {candidate}")
554
+
555
+ if errors:
556
+ print("Preflight checks failed:")
557
+ for err in errors:
558
+ print(f" - {err}")
559
+ return False
560
+
561
+ if os.environ.get("PYTEST_CURRENT_TEST"):
562
+ print("Preflight checks passed.")
563
+
564
+ return True
565
+
468
566
 
469
567
  def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, backup_type: str) -> List[str]:
470
568
  """
@@ -500,14 +598,16 @@ def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, ba
500
598
  backup_definitions.append((file.split('.')[0], os.path.join(root, file)))
501
599
 
502
600
  for backup_definition, backup_definition_path in backup_definitions:
601
+ start_len = len(results)
602
+ success = True
503
603
  try:
504
604
  date = datetime.now().strftime('%Y-%m-%d')
505
605
  backup_file = os.path.join(config_settings.backup_dir, f"{backup_definition}_{backup_type}_{date}")
506
606
 
507
607
  if os.path.exists(backup_file + '.1.dar'):
508
608
  msg = f"Backup file {backup_file}.1.dar already exists. Skipping backup [1]."
509
- logger.error(msg)
510
- results.append((msg, 1))
609
+ logger.warning(msg)
610
+ results.append((msg, 2))
511
611
  continue
512
612
 
513
613
  latest_base_backup = None
@@ -551,19 +651,125 @@ def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, ba
551
651
  else:
552
652
  msg = f"Verification of '{backup_file}' failed."
553
653
  logger.error(msg)
554
- results.append((msg, 1))
654
+ results.append((msg, 2))
555
655
  logger.info("Generate par2 redundancy files.")
556
- generate_par2_files(backup_file, config_settings, args)
656
+ generate_par2_files(backup_file, config_settings, args, backup_definition=backup_definition)
557
657
  logger.info("par2 files completed successfully.")
558
658
 
559
659
  except Exception as e:
560
660
  results.append((repr(e), 1))
561
661
  logger.exception(f"Error during {backup_type} backup process, continuing to next backup definition.")
662
+ success = False
663
+ finally:
664
+ # Determine status based on new results for this backup definition
665
+ new_results = results[start_len:]
666
+ has_error = any(code == 1 for _, code in new_results)
667
+ has_warning = any(code == 2 for _, code in new_results)
668
+ if has_error:
669
+ success = False
670
+
671
+ # Avoid spamming from example/demo backup definitions
672
+ if backup_definition.lower() == "example":
673
+ logger.debug("Skipping Discord notification for example backup definition.")
674
+ continue
675
+
676
+ if has_error:
677
+ status = "FAILURE"
678
+ elif has_warning:
679
+ status = "WARNING"
680
+ else:
681
+ status = "SUCCESS"
682
+ timestamp = datetime.now().strftime("%Y-%m-%d_%H:%M")
683
+ message = f"{timestamp} - dar-backup, {backup_definition}: {status}"
684
+ if not send_discord_message(message, config_settings=config_settings):
685
+ logger.debug(f"Discord notification not sent for {backup_definition}: {status}")
562
686
 
563
687
  logger.trace(f"perform_backup() results[]: {results}")
564
688
  return results
565
689
 
566
- def generate_par2_files(backup_file: str, config_settings: ConfigSettings, args):
690
+ def _parse_archive_base(backup_file: str) -> str:
691
+ return os.path.basename(backup_file)
692
+
693
+
694
+ def _list_dar_slices(archive_dir: str, archive_base: str) -> List[str]:
695
+ pattern = re.compile(rf"{re.escape(archive_base)}\.([0-9]+)\.dar$")
696
+ dar_slices: List[str] = []
697
+
698
+ for filename in os.listdir(archive_dir):
699
+ match = pattern.match(filename)
700
+ if match:
701
+ dar_slices.append(filename)
702
+
703
+ dar_slices.sort(key=lambda x: int(pattern.match(x).group(1)))
704
+ return dar_slices
705
+
706
+
707
+ def _validate_slice_sequence(dar_slices: List[str], archive_base: str) -> None:
708
+ pattern = re.compile(rf"{re.escape(archive_base)}\.([0-9]+)\.dar$")
709
+ if not dar_slices:
710
+ raise RuntimeError(f"No dar slices found for archive base: {archive_base}")
711
+ slice_numbers = [int(pattern.match(s).group(1)) for s in dar_slices]
712
+ expected = list(range(1, max(slice_numbers) + 1))
713
+ if slice_numbers != expected:
714
+ raise RuntimeError(f"Missing dar slices for archive {archive_base}: expected {expected}, got {slice_numbers}")
715
+
716
+
717
+ def _get_backup_type_from_archive_base(archive_base: str) -> str:
718
+ parts = archive_base.split('_')
719
+ if len(parts) < 3:
720
+ raise RuntimeError(f"Unexpected archive name format: {archive_base}")
721
+ return parts[1]
722
+
723
+
724
+ def _get_par2_ratio(backup_type: str, par2_config: dict, default_ratio: int) -> int:
725
+ backup_type = backup_type.upper()
726
+ if backup_type == "FULL" and par2_config.get("par2_ratio_full") is not None:
727
+ return par2_config["par2_ratio_full"]
728
+ if backup_type == "DIFF" and par2_config.get("par2_ratio_diff") is not None:
729
+ return par2_config["par2_ratio_diff"]
730
+ if backup_type == "INCR" and par2_config.get("par2_ratio_incr") is not None:
731
+ return par2_config["par2_ratio_incr"]
732
+ return default_ratio
733
+
734
+
735
+ def _write_par2_manifest(
736
+ manifest_path: str,
737
+ archive_dir_relative: str,
738
+ archive_base: str,
739
+ archive_files: List[str],
740
+ dar_backup_version: str,
741
+ dar_version: str
742
+ ) -> None:
743
+ config = configparser.ConfigParser()
744
+ config["MANIFEST"] = {
745
+ "archive_dir_relative": archive_dir_relative,
746
+ "archive_base": archive_base,
747
+ "dar_backup_version": dar_backup_version,
748
+ "dar_version": dar_version,
749
+ "created_utc": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"),
750
+ }
751
+ config["ARCHIVE_FILES"] = {
752
+ "files": "\n".join(archive_files)
753
+ }
754
+
755
+ with open(manifest_path, "w", encoding="utf-8") as f:
756
+ config.write(f)
757
+
758
+
759
+ def _default_par2_config(config_settings: ConfigSettings) -> dict:
760
+ return {
761
+ "par2_dir": getattr(config_settings, "par2_dir", None),
762
+ "par2_layout": getattr(config_settings, "par2_layout", "by-backup"),
763
+ "par2_mode": getattr(config_settings, "par2_mode", None),
764
+ "par2_ratio_full": getattr(config_settings, "par2_ratio_full", None),
765
+ "par2_ratio_diff": getattr(config_settings, "par2_ratio_diff", None),
766
+ "par2_ratio_incr": getattr(config_settings, "par2_ratio_incr", None),
767
+ "par2_run_verify": getattr(config_settings, "par2_run_verify", None),
768
+ "par2_enabled": getattr(config_settings, "par2_enabled", True),
769
+ }
770
+
771
+
772
+ def generate_par2_files(backup_file: str, config_settings: ConfigSettings, args, backup_definition: str = None):
567
773
  """
568
774
  Generate PAR2 files for a given backup file in the specified backup directory.
569
775
 
@@ -571,6 +777,7 @@ def generate_par2_files(backup_file: str, config_settings: ConfigSettings, args)
571
777
  backup_file (str): The name of the backup file.
572
778
  config_settings: The configuration settings object.
573
779
  args: The command-line arguments object.
780
+ backup_definition (str): The backup definition name used for per-backup overrides.
574
781
 
575
782
  Raises:
576
783
  subprocess.CalledProcessError: If the par2 command fails to execute.
@@ -578,38 +785,58 @@ def generate_par2_files(backup_file: str, config_settings: ConfigSettings, args)
578
785
  Returns:
579
786
  None
580
787
  """
581
- # Regular expression to match DAR slice files
582
- dar_slice_pattern = re.compile(rf"{re.escape(os.path.basename(backup_file))}\.([0-9]+)\.dar")
583
-
584
- # List of DAR slice files to be processed
585
- dar_slices: List[str] = []
586
-
587
- for filename in os.listdir(config_settings.backup_dir):
588
- match = dar_slice_pattern.match(filename)
589
- if match:
590
- dar_slices.append(filename)
591
-
592
- # Sort the DAR slices based on the slice number
593
- dar_slices.sort(key=lambda x: int(dar_slice_pattern.match(x).group(1)))
788
+ if hasattr(config_settings, "get_par2_config"):
789
+ par2_config = config_settings.get_par2_config(backup_definition)
790
+ else:
791
+ par2_config = _default_par2_config(config_settings)
792
+ if not par2_config.get("par2_enabled", False):
793
+ logger.debug("PAR2 disabled for this backup definition, skipping.")
794
+ return
795
+
796
+ archive_dir = config_settings.backup_dir
797
+ archive_base = _parse_archive_base(backup_file)
798
+ backup_type = _get_backup_type_from_archive_base(archive_base)
799
+ par2_dir = par2_config.get("par2_dir")
800
+ if par2_dir:
801
+ par2_dir = os.path.expanduser(os.path.expandvars(par2_dir))
802
+ os.makedirs(par2_dir, exist_ok=True)
803
+
804
+ ratio = _get_par2_ratio(backup_type, par2_config, config_settings.error_correction_percent)
805
+
806
+ dar_slices = _list_dar_slices(archive_dir, archive_base)
807
+ _validate_slice_sequence(dar_slices, archive_base)
594
808
  number_of_slices = len(dar_slices)
595
- counter = 1
596
-
597
- for slice_file in dar_slices:
598
- file_path = os.path.join(config_settings.backup_dir, slice_file)
599
-
600
- logger.info(f"{counter}/{number_of_slices}: Now generating par2 files for {file_path}")
601
-
602
- # Run the par2 command to generate redundancy files with error correction
603
- command = ['par2', 'create', f'-r{config_settings.error_correction_percent}', '-q', '-q', file_path]
604
- process = runner.run(command, timeout = config_settings.command_timeout_secs)
605
809
 
606
- if process.returncode == 0:
607
- logger.info(f"{counter}/{number_of_slices}: Done")
608
- else:
609
- logger.error(f"Error generating par2 files for {file_path}")
610
- raise subprocess.CalledProcessError(process.returncode, command)
611
- counter += 1
810
+ par2_output_dir = par2_dir or archive_dir
811
+ par2_path = os.path.join(par2_output_dir, f"{archive_base}.par2")
812
+ dar_slice_paths = [os.path.join(archive_dir, slice_file) for slice_file in dar_slices]
813
+ logger.info(f"Generating par2 set for archive: {archive_base}")
814
+ command = ['par2', 'create', '-B', archive_dir, f'-r{ratio}', '-q', '-q', par2_path] + dar_slice_paths
815
+ process = runner.run(command, timeout=config_settings.command_timeout_secs)
816
+ if process.returncode != 0:
817
+ logger.error(f"Error generating par2 files for {archive_base}")
818
+ raise subprocess.CalledProcessError(process.returncode, command)
819
+
820
+ if par2_dir:
821
+ archive_dir_relative = os.path.relpath(archive_dir, par2_dir)
822
+ manifest_path = f"{par2_path}.manifest.ini"
823
+ _write_par2_manifest(
824
+ manifest_path=manifest_path,
825
+ archive_dir_relative=archive_dir_relative,
826
+ archive_base=archive_base,
827
+ archive_files=dar_slices,
828
+ dar_backup_version=about.__version__,
829
+ dar_version=getattr(args, "dar_version", "unknown")
830
+ )
831
+ logger.info(f"Wrote par2 manifest: {manifest_path}")
612
832
 
833
+ if par2_config.get("par2_run_verify"):
834
+ logger.info(f"Verifying par2 set for archive: {archive_base}")
835
+ verify_command = ['par2', 'verify', '-B', archive_dir, par2_path]
836
+ verify_process = runner.run(verify_command, timeout=config_settings.command_timeout_secs)
837
+ if verify_process.returncode != 0:
838
+ raise subprocess.CalledProcessError(verify_process.returncode, verify_command)
839
+ return
613
840
 
614
841
 
615
842
  def filter_darrc_file(darrc_path):
@@ -765,6 +992,14 @@ def print_readme(path: str = None, pretty: bool = True):
765
992
  path = Path(__file__).parent / "README.md"
766
993
  print_markdown(str(path), pretty=pretty)
767
994
 
995
+ def list_definitions(backup_d_dir: str) -> List[str]:
996
+ """
997
+ Return backup definition filenames from BACKUP.D_DIR, sorted by name.
998
+ """
999
+ dir_path = Path(backup_d_dir)
1000
+ if not dir_path.is_dir():
1001
+ raise RuntimeError(f"BACKUP.D_DIR does not exist or is not a directory: {backup_d_dir}")
1002
+ return sorted([entry.name for entry in dir_path.iterdir() if entry.is_file()])
768
1003
 
769
1004
 
770
1005
  def main():
@@ -782,15 +1017,24 @@ def main():
782
1017
  parser.add_argument('-I', '--incremental-backup', action='store_true', help="Perform incremental backup.")
783
1018
  parser.add_argument('-d', '--backup-definition', help="Specific 'recipe' to select directories and files.").completer = backup_definition_completer
784
1019
  parser.add_argument('--alternate-reference-archive', help="DIFF or INCR compared to specified archive.").completer = list_archive_completer
785
- parser.add_argument('-c', '--config-file', type=str, help="Path to 'dar-backup.conf'", default='~/.config/dar-backup/dar-backup.conf')
1020
+ parser.add_argument('-c', '--config-file', type=str, help="Path to 'dar-backup.conf'", default=None)
786
1021
  parser.add_argument('--darrc', type=str, help='Optional path to .darrc')
787
- parser.add_argument('-l', '--list', action='store_true', help="List available archives.").completer = list_archive_completer
1022
+ parser.add_argument(
1023
+ '-l',
1024
+ '--list',
1025
+ nargs='?',
1026
+ const=True,
1027
+ default=False,
1028
+ help="List available archives.",
1029
+ ).completer = list_archive_completer
788
1030
  parser.add_argument('--list-contents', help="List the contents of the specified archive.").completer = list_archive_completer
1031
+ parser.add_argument('--list-definitions', action='store_true', help="List available backup definitions from BACKUP.D_DIR.")
789
1032
  parser.add_argument('--selection', type=str, help="Selection string to pass to 'dar', e.g. --selection=\"-I '*.NEF'\"")
790
1033
  # parser.add_argument('-r', '--restore', nargs=1, type=str, help="Restore specified archive.")
791
1034
  parser.add_argument('-r', '--restore', type=str, help="Restore specified archive.").completer = list_archive_completer
792
1035
  parser.add_argument('--restore-dir', type=str, help="Directory to restore files to.")
793
1036
  parser.add_argument('--verbose', action='store_true', help="Print various status messages to screen")
1037
+ parser.add_argument('--preflight-check', action='store_true', help="Run preflight checks and exit")
794
1038
  parser.add_argument('--suppress-dar-msg', action='store_true', help="cancel dar options in .darrc: -vt, -vs, -vd, -vf and -va")
795
1039
  parser.add_argument('--log-level', type=str, help="`debug` or `trace`", default="info")
796
1040
  parser.add_argument('--log-stdout', action='store_true', help='also print log messages to stdout')
@@ -804,6 +1048,11 @@ def main():
804
1048
 
805
1049
  argcomplete.autocomplete(parser)
806
1050
  args = parser.parse_args()
1051
+ # Ensure new flags are present when parse_args is mocked in tests
1052
+ if not hasattr(args, "preflight_check"):
1053
+ args.preflight_check = False
1054
+ if not hasattr(args, "list_definitions"):
1055
+ args.list_definitions = False
807
1056
 
808
1057
  if args.version:
809
1058
  show_version()
@@ -825,19 +1074,56 @@ def main():
825
1074
  exit(0)
826
1075
 
827
1076
 
1077
+ # be backwards compatible with older versions
1078
+ DEFAULT_CONFIG_FILE = "~/.config/dar-backup/dar-backup.conf"
1079
+
1080
+ env_cf = os.getenv("DAR_BACKUP_CONFIG_FILE")
1081
+ env_cf = env_cf.strip() if env_cf else None
828
1082
 
829
- if not args.config_file:
830
- print(f"Config file not specified, exiting", file=stderr)
831
- exit(1)
1083
+ cli_cf = args.config_file.strip() if args.config_file else None
832
1084
 
833
- config_settings_path = os.path.expanduser(os.path.expandvars(args.config_file))
834
- if not os.path.exists(config_settings_path):
835
- print(f"Config file {args.config_file} does not exist.", file=stderr)
836
- exit(127)
1085
+ raw_config = (
1086
+ cli_cf
1087
+ or env_cf
1088
+ or DEFAULT_CONFIG_FILE
1089
+ )
1090
+
1091
+ config_settings_path = get_config_file(args)
1092
+
1093
+ if not (os.path.isfile(config_settings_path) and os.access(config_settings_path, os.R_OK)):
1094
+ print(f"Config file {config_settings_path} must exist and be readable.", file=stderr)
1095
+ raise SystemExit(127)
837
1096
 
838
1097
  args.config_file = config_settings_path
839
1098
  config_settings = ConfigSettings(args.config_file)
840
1099
 
1100
+ if args.list_definitions:
1101
+ try:
1102
+ for name in list_definitions(config_settings.backup_d_dir):
1103
+ print(name)
1104
+ except RuntimeError as exc:
1105
+ print(str(exc), file=stderr)
1106
+ exit(127)
1107
+ exit(0)
1108
+
1109
+ try:
1110
+ validate_required_directories(config_settings)
1111
+ except RuntimeError as exc:
1112
+ ts = datetime.now().strftime("%Y-%m-%d_%H:%M")
1113
+ send_discord_message(f"{ts} - dar-backup: FAILURE - {exc}", config_settings=config_settings)
1114
+ print(str(exc), file=stderr)
1115
+ exit(127)
1116
+
1117
+ # Run preflight checks always; if --preflight-check is set, exit afterward.
1118
+ ok = preflight_check(args, config_settings)
1119
+ if not ok:
1120
+ ts = datetime.now().strftime("%Y-%m-%d_%H:%M")
1121
+ send_discord_message(f"{ts} - dar-backup: FAILURE - preflight checks failed", config_settings=config_settings)
1122
+ exit_code = 127 if args.backup_definition else 1
1123
+ exit(exit_code)
1124
+ if args.preflight_check:
1125
+ exit(0)
1126
+
841
1127
  command_output_log = config_settings.logfile_location.replace("dar-backup.log", "dar-backup-commands.log")
842
1128
  if command_output_log == config_settings.logfile_location:
843
1129
  print(f"Error: logfile_location in {args.config_file} does not end at 'dar-backup.log', exiting", file=stderr)
@@ -873,6 +1159,7 @@ def main():
873
1159
  logger.debug(f"`Args`:\n{args}")
874
1160
  logger.debug(f"`Config_settings`:\n{config_settings}")
875
1161
  dar_properties = get_binary_info(command='dar')
1162
+ args.dar_version = dar_properties.get('version', 'unknown')
876
1163
  start_msgs.append(('dar path:', dar_properties['path']))
877
1164
  start_msgs.append(('dar version:', dar_properties['version']))
878
1165
 
@@ -915,7 +1202,14 @@ def main():
915
1202
  requirements('PREREQ', config_settings)
916
1203
 
917
1204
  if args.list:
918
- list_backups(config_settings.backup_dir, args.backup_definition)
1205
+ list_filter = args.backup_definition
1206
+ if isinstance(args.list, str):
1207
+ if list_filter:
1208
+ if args.list.startswith(list_filter):
1209
+ list_filter = args.list
1210
+ else:
1211
+ list_filter = args.list
1212
+ list_backups(config_settings.backup_dir, list_filter)
919
1213
  elif args.full_backup and not args.differential_backup and not args.incremental_backup:
920
1214
  results.extend(perform_backup(args, config_settings, "FULL"))
921
1215
  elif args.differential_backup and not args.full_backup and not args.incremental_backup:
@@ -951,6 +1245,7 @@ def main():
951
1245
 
952
1246
  # Determine exit code
953
1247
  error = False
1248
+ final_exit_code = 0
954
1249
  logger.debug(f"results[]: {results}")
955
1250
  if results:
956
1251
  i = 0
@@ -961,15 +1256,21 @@ def main():
961
1256
  if exit_code > 0:
962
1257
  error = True
963
1258
  args.verbose and print(msg)
1259
+ if exit_code == 1:
1260
+ final_exit_code = 1
1261
+ elif exit_code == 2 and final_exit_code == 0:
1262
+ final_exit_code = 2
964
1263
  else:
965
1264
  logger.error(f"not correct result type: {result}, which must be a tuple (<msg>, <exit_code>)")
1265
+ error = True
1266
+ final_exit_code = 1
966
1267
  i=i+1
967
1268
 
968
1269
  console = Console()
969
1270
  if error:
970
1271
  if args.verbose:
971
1272
  console.print(Text("Errors encountered", style="bold red"))
972
- exit(1)
1273
+ exit(final_exit_code or 1)
973
1274
  else:
974
1275
  if args.verbose:
975
1276
  console.print(Text("Success: all backups completed", style="bold green"))