dar-backup 0.6.19__py3-none-any.whl → 0.6.20.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/Changelog.md CHANGED
@@ -1,6 +1,31 @@
1
1
  <!-- markdownlint-disable MD024 -->
2
2
  # dar-backup Changelog
3
3
 
4
+ ## v2-beta-0.6.20.1 - 2025-05-04
5
+
6
+ Github link: [v2-beta-0.6.20.1](https://github.com/per2jensen/dar-backup/tree/v2-beta-0.6.20.1/v2)
7
+
8
+ ### Added
9
+
10
+ - FIX: bash/zsh completers fixed to support MANAGER_DB_DIR config if set
11
+ - `cleanup` and `manager` completer now sorts archives by \<backup-definition> and \<archive date> (so not using \<type>)
12
+
13
+ ## v2-beta-0.6.20 - 2025-05-03
14
+
15
+ Github link: [v2-beta-0.6.20](https://github.com/per2jensen/dar-backup/tree/v2-beta-0.6.20/v2)
16
+
17
+ ### Added
18
+
19
+ - show_version() moved to util and tests for dar-backup, manager and cleanup
20
+ - startup informational messages now works the same across the scripts
21
+ - Improved ConfigSettings class to handle optional configuration keys
22
+
23
+ -- test cases added
24
+
25
+ - Optional config parameter: MANAGER_DB_DIR, ideally to point to another disk for safe keeping backup catalogs
26
+
27
+ -- test cases added
28
+
4
29
  ## v2-beta-0.6.19 - 2025-04-21
5
30
 
6
31
  Github link: [v2-beta-0.6.19](https://github.com/per2jensen/dar-backup/tree/v2-beta-0.6.19/v2)
dar_backup/__about__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.6.19"
1
+ __version__ = "0.6.20.1"
dar_backup/cleanup.py CHANGED
@@ -20,11 +20,15 @@ import os
20
20
  import re
21
21
  import subprocess
22
22
  import sys
23
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
24
+
25
+
23
26
 
24
27
  from datetime import datetime, timedelta
25
28
  from inputimeout import inputimeout, TimeoutOccurred
26
29
  from time import time
27
- from typing import Dict, List, NamedTuple
30
+ from typing import Dict, List, NamedTuple, Tuple
31
+
28
32
 
29
33
  from . import __about__ as about
30
34
  from dar_backup.config_settings import ConfigSettings
@@ -32,6 +36,8 @@ from dar_backup.util import list_backups
32
36
  from dar_backup.util import setup_logging
33
37
  from dar_backup.util import get_logger
34
38
  from dar_backup.util import requirements
39
+ from dar_backup.util import show_version
40
+ from dar_backup.util import print_aligned_settings
35
41
  from dar_backup.util import backup_definition_completer, list_archive_completer
36
42
 
37
43
  from dar_backup.command_runner import CommandRunner
@@ -152,21 +158,12 @@ def delete_catalog(catalog_name: str, args: NamedTuple) -> bool:
152
158
  return False
153
159
 
154
160
 
155
- def show_version():
156
- script_name = os.path.basename(sys.argv[0])
157
- print(f"{script_name} {about.__version__}")
158
- print('''Licensed under GNU GENERAL PUBLIC LICENSE v3, see the supplied file "LICENSE" for details.
159
- THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW, not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
160
- See section 15 and section 16 in the supplied "LICENSE" file.''')
161
-
162
-
163
161
  def confirm_full_archive_deletion(archive_name: str, test_mode=False) -> bool:
164
162
  try:
165
163
  if test_mode:
166
- confirmation = os.environ.get("CLEANUP_TEST_DELETE_FULL")
167
- if confirmation is None:
168
- raise RuntimeError("envvar 'CLEANUP_TEST_DELETE_FULL' not set")
169
- print(f"Simulated confirmation for FULL archive: {confirmation}")
164
+ answer = os.getenv("CLEANUP_TEST_DELETE_FULL", "").lower()
165
+ print(f"Simulated confirmation for FULL archive '{archive_name}': {answer}")
166
+ return answer == "yes"
170
167
  else:
171
168
  confirmation = inputimeout(
172
169
  prompt=f"Are you sure you want to delete the FULL archive '{archive_name}'? (yes/no): ",
@@ -220,21 +217,24 @@ def main():
220
217
  command_logger = get_logger(command_output_logger = True)
221
218
  runner = CommandRunner(logger=logger, command_logger=command_logger)
222
219
 
223
- logger.info(f"=====================================")
224
- logger.info(f"cleanup.py started, version: {about.__version__}")
220
+ start_msgs: List[Tuple[str, str]] = []
221
+
222
+ start_msgs.append(("cleanup.py:", about.__version__))
225
223
 
226
224
  logger.info(f"START TIME: {start_time}")
227
225
  logger.debug(f"`args`:\n{args}")
228
226
  logger.debug(f"`config_settings`:\n{config_settings}")
229
227
 
230
228
  file_dir = os.path.normpath(os.path.dirname(__file__))
231
- args.verbose and (print(f"Script directory: {file_dir}"))
232
- args.verbose and (print(f"Config file: {args.config_file}"))
233
- args.verbose and (print(f"Backup dir: {config_settings.backup_dir}"))
234
- args.verbose and (print(f"Logfile location: {config_settings.logfile_location}"))
235
- args.verbose and (print(f"--alternate-archive-dir: {args.alternate_archive_dir}"))
236
- args.verbose and (print(f"--cleanup-specific-archives:{args.cleanup_specific_archives}"))
237
-
229
+ args.verbose and start_msgs.append(("Script directory:", file_dir))
230
+ start_msgs.append(("Config file:", args.config_file))
231
+ args.verbose and start_msgs.append(("Backup dir:", config_settings.backup_dir))
232
+ start_msgs.append(("Logfile:", config_settings.logfile_location))
233
+ args.verbose and start_msgs.append(("--alternate-archive-dir:", args.alternate_archive_dir))
234
+ args.verbose and start_msgs.append(("--cleanup-specific-archives:", args.cleanup_specific_archives))
235
+
236
+ dangerous_keywords = ["--cleanup", "_FULL_"] # TODO: add more dangerous keywords
237
+ print_aligned_settings(start_msgs, highlight_keywords=dangerous_keywords)
238
238
 
239
239
  # run PREREQ scripts
240
240
  requirements('PREREQ', config_settings)
@@ -1,33 +1,35 @@
1
-
2
1
  import configparser
3
- import logging
4
- import sys
5
-
6
2
  from dataclasses import dataclass, field, fields
7
3
  from os.path import expandvars, expanduser
8
4
  from pathlib import Path
9
5
 
6
+ from dar_backup.exceptions import ConfigSettingsError
7
+
10
8
  @dataclass
11
9
  class ConfigSettings:
12
10
  """
13
- A dataclass for holding configuration settings, initialized from a configuration file.
14
-
15
- Attributes:
16
- logfile_location (str): The location of the log file.
17
- max_size_verification_mb (int): The maximum size for verification in megabytes.
18
- min_size_verification_mb (int): The minimum size for verification in megabytes.
19
- no_files_verification (int): The number of files for verification.
20
- command_timeout_secs (int): The timeout in seconds for commands.
21
- backup_dir (str): The directory for backups.
22
- test_restore_dir (str): The directory for test restores.
23
- backup_d_dir (str): The directory for backup.d.
24
- diff_age (int): The age for differential backups before deletion.
25
- incr_age (int): The age for incremental backups before deletion.
26
- error_correction_percent (int): The error correction percentage for PAR2.
27
- par2_enabled (bool): Whether PAR2 is enabled.
11
+ Parses and holds configuration values from a dar-backup.conf file.
12
+
13
+ Required fields are defined as dataclass attributes and must be present in the config file.
14
+ Optional fields can be declared in the OPTIONAL_CONFIG_FIELDS list. If a key is present in the
15
+ config file, the field is set to its parsed value; otherwise, it defaults to None.
16
+
17
+ The __repr__ method will only include optional fields if their value is not None,
18
+ keeping debug output clean and focused on explicitly configured values.
19
+
20
+ OPTIONAL_CONFIG_FIELDS = [
21
+ {
22
+ "section": "DIRECTORIES",
23
+ "key": "MANAGER_DB_DIR",
24
+ "attr": "manager_db_dir",
25
+ "type": str,
26
+ "default": None,
27
+ }
28
+ ]
28
29
  """
29
30
 
30
31
  config_file: str
32
+
31
33
  logfile_location: str = field(init=False)
32
34
  max_size_verification_mb: int = field(init=False)
33
35
  min_size_verification_mb: int = field(init=False)
@@ -41,17 +43,28 @@ class ConfigSettings:
41
43
  error_correction_percent: int = field(init=False)
42
44
  par2_enabled: bool = field(init=False)
43
45
 
46
+
47
+ OPTIONAL_CONFIG_FIELDS = [
48
+ {
49
+ "section": "DIRECTORIES",
50
+ "key": "MANAGER_DB_DIR",
51
+ "attr": "manager_db_dir",
52
+ "type": str,
53
+ "default": None,
54
+ },
55
+ # Add more optional fields here
56
+ ]
57
+
44
58
  def __post_init__(self):
45
- """
46
- Initializes the ConfigSettings instance by reading the specified configuration file
47
- and expands environment variables for all string fields.
48
- """
49
- if self.config_file is None:
50
- raise ValueError("`config_file` must be specified.")
51
-
52
- self.config = configparser.ConfigParser()
59
+ if not self.config_file:
60
+ raise ConfigSettingsError("`config_file` must be specified.")
61
+
53
62
  try:
54
- self.config.read(self.config_file)
63
+ self.config = configparser.ConfigParser()
64
+ loaded_files = self.config.read(self.config_file)
65
+ if not loaded_files:
66
+ raise RuntimeError(f"Configuration file not found or unreadable: '{self.config_file}'")
67
+
55
68
  self.logfile_location = self.config['MISC']['LOGFILE_LOCATION']
56
69
  self.max_size_verification_mb = int(self.config['MISC']['MAX_SIZE_VERIFICATION_MB'])
57
70
  self.min_size_verification_mb = int(self.config['MISC']['MIN_SIZE_VERIFICATION_MB'])
@@ -63,37 +76,54 @@ class ConfigSettings:
63
76
  self.diff_age = int(self.config['AGE']['DIFF_AGE'])
64
77
  self.incr_age = int(self.config['AGE']['INCR_AGE'])
65
78
  self.error_correction_percent = int(self.config['PAR2']['ERROR_CORRECTION_PERCENT'])
79
+
66
80
  val = self.config['PAR2']['ENABLED'].strip().lower()
67
81
  if val in ('true', '1', 'yes'):
68
82
  self.par2_enabled = True
69
83
  elif val in ('false', '0', 'no'):
70
84
  self.par2_enabled = False
71
85
  else:
72
- raise ValueError(f"Invalid boolean value for 'ENABLED' in [PAR2]: '{val}'")
73
-
74
- # Ensure the directories exist
75
- Path(self.backup_dir).mkdir(parents=True, exist_ok=True)
76
- Path(self.test_restore_dir).mkdir(parents=True, exist_ok=True)
77
- Path(self.backup_d_dir).mkdir(parents=True, exist_ok=True)
78
-
79
- # Expand environment variables for all string fields
86
+ raise ConfigSettingsError(f"Invalid boolean value for 'ENABLED' in [PAR2]: '{val}'")
87
+
88
+ # Load optional fields
89
+ for opt in self.OPTIONAL_CONFIG_FIELDS:
90
+ if self.config.has_option(opt['section'], opt['key']):
91
+ raw_value = self.config.get(opt['section'], opt['key'])
92
+ try:
93
+ value = opt['type'](raw_value.strip())
94
+ setattr(self, opt['attr'], value)
95
+ except Exception as e:
96
+ raise ConfigSettingsError(
97
+ f"Failed to parse optional config '{opt['section']}::{opt['key']}': {e}"
98
+ )
99
+ else:
100
+ setattr(self, opt['attr'], opt.get('default', None))
101
+
102
+
103
+ # Expand paths in all string fields that exist
80
104
  for field in fields(self):
81
- if isinstance(getattr(self, field.name), str):
82
- setattr(self, field.name, expanduser(expandvars(getattr(self, field.name))))
83
-
84
- except FileNotFoundError as e:
85
- logging.error(f"Configuration file not found: {self.config_file}")
86
- logging.error(f"Error details: {e}")
87
- sys.exit("Error: Configuration file not found.")
88
- except PermissionError as e:
89
- logging.error(f"Permission error while reading config file {self.config_file}")
90
- logging.error(f"Error details: {e}")
91
- sys.exit("Error: Permission error while reading config file.")
105
+ if hasattr(self, field.name):
106
+ value = getattr(self, field.name)
107
+ if isinstance(value, str):
108
+ setattr(self, field.name, expanduser(expandvars(value)))
109
+
110
+ except RuntimeError as e:
111
+ raise ConfigSettingsError(f"RuntimeError: {e}")
92
112
  except KeyError as e:
93
- logging.error(f"Missing mandatory configuration key: {e}")
94
- logging.error(f"Error details: {e}")
95
- sys.exit(f"Error: Missing mandatory configuration key: {e}.")
113
+ raise ConfigSettingsError(f"Missing mandatory configuration key: {e}")
114
+ except ValueError as e:
115
+ raise ConfigSettingsError(f"Invalid value in config: {e}")
96
116
  except Exception as e:
97
- logging.exception(f"Unexpected error reading config file {self.config_file}: {e}")
98
- logging.error(f"Error details: {e}")
99
- sys.exit(f"Unexpected error reading config file: {e}.")
117
+ raise ConfigSettingsError(f"Unexpected error during config initialization: {e}")
118
+
119
+
120
+
121
+ def __repr__(self):
122
+ safe_fields = [
123
+ f"{field.name}={getattr(self, field.name)!r}"
124
+ for field in fields(self)
125
+ if hasattr(self, field.name) and getattr(self, field.name) is not None
126
+ ]
127
+ return f"<ConfigSettings({', '.join(safe_fields)})>"
128
+
129
+
@@ -16,8 +16,12 @@ COMMAND_TIMEOUT_SECS = 86400
16
16
  BACKUP_DIR = ~/dar-backup/backups
17
17
  BACKUP.D_DIR = ~/.config/dar-backup/backup.d/
18
18
  TEST_RESTORE_DIR = ~/dar-backup/restore/
19
+ # Optional parameter
20
+ # If you want to store the catalog database away from the BACKUP_DIR, use the MANAGER_DB_DIR variable.
21
+ #MANAGER_DB_DIR = /some/where/else/
19
22
 
20
23
  [AGE]
24
+ # DIFF and INCR backups are kept for a configured number of days, then deleted by the `cleanuo`
21
25
  # age settings are in days
22
26
  DIFF_AGE = 100
23
27
  INCR_AGE = 40
dar_backup/dar_backup.py CHANGED
@@ -37,8 +37,10 @@ from sys import stderr
37
37
  from sys import argv
38
38
  from sys import version_info
39
39
  from time import time
40
+ from rich.console import Console
41
+ from rich.text import Text
40
42
  from threading import Event
41
- from typing import List
43
+ from typing import List, Tuple
42
44
 
43
45
  from . import __about__ as about
44
46
  from dar_backup.config_settings import ConfigSettings
@@ -48,7 +50,9 @@ from dar_backup.util import get_logger
48
50
  from dar_backup.util import BackupError
49
51
  from dar_backup.util import RestoreError
50
52
  from dar_backup.util import requirements
53
+ from dar_backup.util import show_version
51
54
  from dar_backup.util import get_binary_info
55
+ from dar_backup.util import print_aligned_settings
52
56
  from dar_backup.util import backup_definition_completer, list_archive_completer
53
57
 
54
58
  from dar_backup.command_runner import CommandRunner
@@ -647,14 +651,6 @@ def filter_darrc_file(darrc_path):
647
651
 
648
652
 
649
653
 
650
- def show_version():
651
- script_name = os.path.basename(argv[0])
652
- print(f"{script_name} {about.__version__}")
653
- print(f"dar-backup.py source code is here: https://github.com/per2jensen/dar-backup")
654
- print('''Licensed under GNU GENERAL PUBLIC LICENSE v3, see the supplied file "LICENSE" for details.
655
- THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW, not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
656
- See section 15 and section 16 in the supplied "LICENSE" file.''')
657
-
658
654
 
659
655
  def show_examples():
660
656
  examples = """
@@ -846,35 +842,40 @@ def main():
846
842
  args.darrc = filter_darrc_file(args.darrc)
847
843
  logger.debug(f"Filtered .darrc file: {args.darrc}")
848
844
 
845
+ start_msgs: List[Tuple[str, str]] = []
846
+
849
847
  start_time=int(time())
850
- logger.info(f"=====================================")
851
- logger.info(f"dar-backup.py started, version: {about.__version__}")
848
+ start_msgs.append(('dar-backup.py:', about.__version__))
852
849
  logger.info(f"START TIME: {start_time}")
853
- logger.debug(f"`args`:\n{args}")
854
- logger.debug(f"`config_settings`:\n{config_settings}")
855
- dar_manager_properties = get_binary_info(command='dar')
856
- logger.debug(f"dar path: {dar_manager_properties['path']}")
857
- logger.debug(f"dar version: {dar_manager_properties['version']}")
850
+ logger.debug(f"{'`Args`:\n'}{args}")
851
+ logger.debug(f"{'`Config_settings`:\n'}{config_settings}")
852
+ dar_properties = get_binary_info(command='dar')
853
+ start_msgs.append(('dar path:', dar_properties['path']))
854
+ start_msgs.append(('dar version:', dar_properties['version']))
858
855
 
859
856
  file_dir = os.path.normpath(os.path.dirname(__file__))
860
- args.verbose and (print(f"Script directory: {file_dir}"))
861
- args.verbose and (print(f"Config file: {args.config_file}"))
862
- args.verbose and args.full_backup and (print(f"Type of backup: FULL"))
863
- args.verbose and args.differential_backup and (print(f"Type of backup: DIFF"))
864
- args.verbose and args.incremental_backup and (print(f"Type of backup: INCR"))
865
- args.verbose and args.backup_definition and (print(f"Backup definition: '{args.backup_definition}'"))
857
+ start_msgs.append(('Script directory:', os.path.abspath(file_dir)))
858
+ start_msgs.append(('Config file:', os.path.abspath(args.config_file)))
859
+ start_msgs.append((".darrc location:", args.darrc))
860
+
861
+ args.verbose and args.full_backup and start_msgs.append(("Type of backup:", "FULL"))
862
+ args.verbose and args.differential_backup and start_msgs.append(("Type of backup:", "DIFF"))
863
+ args.verbose and args.incremental_backup and start_msgs.append(("Type of backup:", "INCR"))
864
+ args.verbose and args.backup_definition and start_msgs.append(("Backup definition:", args.backup_definition))
866
865
  if args.alternate_reference_archive:
867
- args.verbose and (print(f"Alternate ref archive: {args.alternate_reference_archive}"))
868
- args.verbose and (print(f"Backup.d dir: {config_settings.backup_d_dir}"))
869
- args.verbose and (print(f"Backup dir: {config_settings.backup_dir}"))
866
+ args.verbose and start_msgs.append(("Alternate ref archive:", args.alternate_reference_archive))
867
+ args.verbose and start_msgs.append(("Backup.d dir:", config_settings.backup_d_dir))
868
+ args.verbose and start_msgs.append(("Backup dir:", config_settings.backup_dir))
870
869
 
871
870
  restore_dir = args.restore_dir if args.restore_dir else config_settings.test_restore_dir
872
- args.verbose and (print(f"Restore dir: {restore_dir}"))
871
+ args.verbose and start_msgs.append(("Restore dir:", restore_dir))
873
872
 
874
- args.verbose and (print(f"Logfile location: {config_settings.logfile_location}"))
875
- args.verbose and (print(f".darrc location: {args.darrc}"))
876
- args.verbose and (print(f"PAR2 enabled: {config_settings.par2_enabled}"))
877
- args.verbose and (print(f"--do-not-compare: {args.do_not_compare}"))
873
+ args.verbose and start_msgs.append(("Logfile location:", config_settings.logfile_location))
874
+ args.verbose and start_msgs.append(("PAR2 enabled:", config_settings.par2_enabled))
875
+ args.verbose and start_msgs.append(("--do-not-compare:", args.do_not_compare))
876
+
877
+ dangerous_keywords = ["--do-not", "alternate"] # TODO: add more dangerous keywords
878
+ print_aligned_settings(start_msgs)
878
879
 
879
880
  # sanity check
880
881
  if args.backup_definition and not os.path.exists(os.path.join(config_settings.backup_d_dir, args.backup_definition)):
@@ -936,12 +937,17 @@ def main():
936
937
  else:
937
938
  logger.error(f"not correct result type: {result}, which must be a tuple (<msg>, <exit_code>)")
938
939
  i=i+1
940
+
941
+ console = Console()
939
942
  if error:
940
- args.verbose and print("\033[1m\033[31mErrors\033[0m encountered")
943
+ if args.verbose:
944
+ console.print(Text("Errors encountered", style="bold red"))
941
945
  exit(1)
942
946
  else:
943
- args.verbose and print("\033[1m\033[32mSuccess\033[0m all backups completed")
947
+ if args.verbose:
948
+ console.print(Text("Success: all backups completed", style="bold green"))
944
949
  exit(0)
950
+
945
951
 
946
952
  if __name__ == "__main__":
947
953
  main()
dar_backup/demo.py ADDED
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ installer.py source code is here: https://github.com/per2jensen/dar-backup/tree/main/v2/src/dar_backup/installer.py
4
+ This script is part of dar-backup, a backup solution for Linux using dar and systemd.
5
+
6
+ Licensed under GNU GENERAL PUBLIC LICENSE v3, see the supplied file "LICENSE" for details.
7
+
8
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW,
9
+ not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
10
+ See section 15 and section 16 in the supplied "LICENSE" file
11
+
12
+ This script can be used to configure dar-backup on your system.
13
+ It is non-destructive and will not overwrite any existing files or directories.
14
+ """
15
+
16
+ import argparse
17
+ import os
18
+ import shutil
19
+ import sys
20
+
21
+ from . import __about__ as about
22
+ from pathlib import Path
23
+
24
+ LICENSE = '''Licensed under GNU GENERAL PUBLIC LICENSE v3, see the supplied file "LICENSE" for details.
25
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW, not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
26
+ See section 15 and section 16 in the supplied "LICENSE" file.'''
27
+
28
+ CONFIG_DIR = os.path.expanduser("~/.config/dar-backup")
29
+ DAR_BACKUP_DIR = os.path.expanduser("~/dar-backup/")
30
+
31
+ BACKUP_DEFINITION = '''
32
+ # Demo of a `dar-backup` definition file
33
+ # This back definition file configures a backup of ~/.config/dar-backup
34
+ # `dar-backup` puts the backups in ~/dar-backup/backups
35
+ # ------------------------------------------------------------------------
36
+
37
+ # Switch to ordered selection mode, which means that the following options
38
+ # will be considered top to bottom
39
+ -am
40
+
41
+ # Backup Root dir
42
+ -R @@HOME_DIR@@
43
+
44
+ # Directories to backup below the Root dir
45
+ -g .config/dar-backup
46
+
47
+ # Examples of directories to exclude below the Root dir
48
+ -P mnt
49
+ -P .private
50
+ -P .cache
51
+
52
+ # compression level
53
+ -z5
54
+
55
+ # no overwrite, if you rerun a backup, 'dar' halts and asks what to do
56
+ -n
57
+
58
+ # size of each slice in the archive
59
+ --slice 10G
60
+
61
+ # bypass directores marked as cache directories
62
+ # http://dar.linux.free.fr/doc/Features.html
63
+ --cache-directory-tagging
64
+ '''
65
+
66
+
67
+ def main():
68
+ parser = argparse.ArgumentParser(
69
+ description="Set up `dar-backup` on your system.",
70
+ )
71
+ parser.add_argument(
72
+ "-i", "--install",
73
+ action="store_true",
74
+ help="Deploy a simple config file, use ~/dar-backup/ for log file, archives and restore tests."
75
+ )
76
+ parser.add_argument(
77
+ "-v", "--version",
78
+ action="version",
79
+ version=f"%(prog)s version {about.__version__}, {LICENSE}"
80
+ )
81
+
82
+ args = parser.parse_args()
83
+
84
+ if args.install:
85
+ errors = []
86
+ if os.path.exists(CONFIG_DIR):
87
+ errors.append(f"Config directory '{CONFIG_DIR}' already exists.")
88
+ if os.path.exists(DAR_BACKUP_DIR):
89
+ errors.append(f"Directory '{DAR_BACKUP_DIR}' already exists.")
90
+
91
+ if errors:
92
+ for error in errors:
93
+ print(f"Error: {error}")
94
+ sys.exit(1)
95
+
96
+ try:
97
+ os.makedirs(DAR_BACKUP_DIR, exist_ok=False)
98
+ os.makedirs(os.path.join(DAR_BACKUP_DIR, "backups"), exist_ok=False)
99
+ os.makedirs(os.path.join(DAR_BACKUP_DIR, "restore"), exist_ok=False)
100
+ os.makedirs(CONFIG_DIR, exist_ok=False)
101
+ os.makedirs(os.path.join(CONFIG_DIR, "backup.d"), exist_ok=False)
102
+ print(f"Directories created: `{DAR_BACKUP_DIR}` and `{CONFIG_DIR}`")
103
+
104
+ script_dir = Path(__file__).parent
105
+ source_file = script_dir / "dar-backup.conf"
106
+ destination_file = Path(CONFIG_DIR) / "dar-backup.conf"
107
+
108
+ try:
109
+ shutil.copy2(source_file, destination_file)
110
+ print(f"Config file deployed to {destination_file}")
111
+ except Exception as e:
112
+ print(f"Error: Could not copy config file: {e}")
113
+ sys.exit(1)
114
+
115
+
116
+ backup_definition = BACKUP_DEFINITION.replace("@@HOME_DIR@@", os.path.expanduser("~"))
117
+
118
+ try:
119
+ with open(os.path.join(CONFIG_DIR, "backup.d", "default"), "w") as f:
120
+ f.write(backup_definition)
121
+ print(f"Default backup definition file deployed to {os.path.join(CONFIG_DIR, 'backup.d', 'default')}")
122
+ except Exception as e:
123
+ print(f"Error: Could not write default backup definition: {e}")
124
+ sys.exit(1)
125
+ except Exception as e:
126
+ print(f"Installation failed: {e}")
127
+ sys.exit(1)
128
+
129
+ print("1. Now run `manager --create` to create the catalog database.")
130
+ print("2. Then you can run `dar-backup --full-backup` to create a backup.")
131
+ print("3. List backups with `dar-backup --list`")
132
+ print("4. List contents of a backup with `dar-backup --list-contents <backup-name>`")
133
+
134
+ sys.exit(0)
135
+
136
+
137
+ if __name__ == "__main__":
138
+ main()
@@ -0,0 +1,3 @@
1
+ class ConfigSettingsError(Exception):
2
+ """Raised when ConfigSettings encounters a critical error."""
3
+ pass
dar_backup/installer.py CHANGED
@@ -1,138 +1,58 @@
1
- #!/usr/bin/env python3
2
- """
3
- installer.py source code is here: https://github.com/per2jensen/dar-backup/tree/main/v2/src/dar_backup/installer.py
4
- This script is part of dar-backup, a backup solution for Linux using dar and systemd.
5
-
6
- Licensed under GNU GENERAL PUBLIC LICENSE v3, see the supplied file "LICENSE" for details.
7
-
8
- THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW,
9
- not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
10
- See section 15 and section 16 in the supplied "LICENSE" file
11
-
12
- This script can be used to configure dar-backup on your system.
13
- It is non-destructive and will not overwrite any existing files or directories.
14
- """
15
-
16
1
  import argparse
17
2
  import os
18
- import shutil
19
- import sys
20
-
21
- from . import __about__ as about
22
3
  from pathlib import Path
23
-
24
- LICENSE = '''Licensed under GNU GENERAL PUBLIC LICENSE v3, see the supplied file "LICENSE" for details.
25
- THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW, not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
26
- See section 15 and section 16 in the supplied "LICENSE" file.'''
27
-
28
- CONFIG_DIR = os.path.expanduser("~/.config/dar-backup")
29
- DAR_BACKUP_DIR = os.path.expanduser("~/dar-backup/")
30
-
31
- BACKUP_DEFINITION = '''
32
- # Demo of a `dar-backup` definition file
33
- # This back definition file configures a backup of ~/.config/dar-backup
34
- # `dar-backup` puts the backups in ~/dar-backup/backups
35
- # ------------------------------------------------------------------------
36
-
37
- # Switch to ordered selection mode, which means that the following options
38
- # will be considered top to bottom
39
- -am
40
-
41
- # Backup Root dir
42
- -R @@HOME_DIR@@
43
-
44
- # Directories to backup below the Root dir
45
- -g .config/dar-backup
46
-
47
- # Examples of directories to exclude below the Root dir
48
- -P mnt
49
- -P .private
50
- -P .cache
51
-
52
- # compression level
53
- -z5
54
-
55
- # no overwrite, if you rerun a backup, 'dar' halts and asks what to do
56
- -n
57
-
58
- # size of each slice in the archive
59
- --slice 10G
60
-
61
- # bypass directores marked as cache directories
62
- # http://dar.linux.free.fr/doc/Features.html
63
- --cache-directory-tagging
64
- '''
65
-
66
-
67
- def main():
68
- parser = argparse.ArgumentParser(
69
- description="Set up `dar-backup` on your system.",
70
- )
71
- parser.add_argument(
72
- "-i", "--install",
73
- action="store_true",
74
- help="Deploy a simple config file, use ~/dar-backup/ for log file, archives and restore tests."
75
- )
76
- parser.add_argument(
77
- "-v", "--version",
78
- action="version",
79
- version=f"%(prog)s version {about.__version__}, {LICENSE}"
4
+ from dar_backup.config_settings import ConfigSettings
5
+ from dar_backup.util import setup_logging, get_logger
6
+ from dar_backup.command_runner import CommandRunner
7
+ from dar_backup.manager import create_db
8
+
9
+
10
+ def run_installer(config_file: str, create_db_flag: bool):
11
+ config_file = os.path.expanduser(os.path.expandvars(config_file))
12
+ config_settings = ConfigSettings(config_file)
13
+
14
+ # Set up logging based on the config's specified log file
15
+ command_log = config_settings.logfile_location.replace("dar-backup.log", "dar-backup-commands.log")
16
+ logger = setup_logging(
17
+ config_settings.logfile_location,
18
+ command_log,
19
+ log_level="info",
20
+ log_stdout=True,
80
21
  )
81
-
22
+ command_logger = get_logger(command_output_logger=True)
23
+ runner = CommandRunner(logger=logger, command_logger=command_logger)
24
+
25
+ # Create directories listed in config
26
+ for attr in ["backup_dir", "test_restore_dir", "backup_d_dir", "manager_db_dir"]:
27
+ path = getattr(config_settings, attr, None)
28
+ if path:
29
+ dir_path = Path(path).expanduser()
30
+ if not dir_path.exists():
31
+ dir_path.mkdir(parents=True, exist_ok=True)
32
+ print(f"Created directory: {dir_path}")
33
+ else:
34
+ print(f"Directory already exists: {dir_path}")
35
+
36
+ # Optionally create databases
37
+ if create_db_flag:
38
+ for file in os.listdir(config_settings.backup_d_dir):
39
+ backup_def = os.path.basename(file)
40
+ print(f"Creating catalog for: {backup_def}")
41
+ result = create_db(backup_def, config_settings, logger)
42
+ if result == 0:
43
+ print(f"✔️ Catalog created (or already existed): {backup_def}")
44
+ else:
45
+ print(f"❌ Failed to create catalog: {backup_def}")
46
+
47
+
48
+ def installer_main():
49
+ parser = argparse.ArgumentParser(description="dar-backup installer")
50
+ parser.add_argument("--config", required=True, help="Path to config file")
51
+ parser.add_argument("--create-db", action="store_true", help="Create catalog databases")
82
52
  args = parser.parse_args()
83
53
 
84
- if args.install:
85
- errors = []
86
- if os.path.exists(CONFIG_DIR):
87
- errors.append(f"Config directory '{CONFIG_DIR}' already exists.")
88
- if os.path.exists(DAR_BACKUP_DIR):
89
- errors.append(f"Directory '{DAR_BACKUP_DIR}' already exists.")
90
-
91
- if errors:
92
- for error in errors:
93
- print(f"Error: {error}")
94
- sys.exit(1)
95
-
96
- try:
97
- os.makedirs(DAR_BACKUP_DIR, exist_ok=False)
98
- os.makedirs(os.path.join(DAR_BACKUP_DIR, "backups"), exist_ok=False)
99
- os.makedirs(os.path.join(DAR_BACKUP_DIR, "restore"), exist_ok=False)
100
- os.makedirs(CONFIG_DIR, exist_ok=False)
101
- os.makedirs(os.path.join(CONFIG_DIR, "backup.d"), exist_ok=False)
102
- print(f"Directories created: `{DAR_BACKUP_DIR}` and `{CONFIG_DIR}`")
103
-
104
- script_dir = Path(__file__).parent
105
- source_file = script_dir / "dar-backup.conf"
106
- destination_file = Path(CONFIG_DIR) / "dar-backup.conf"
107
-
108
- try:
109
- shutil.copy2(source_file, destination_file)
110
- print(f"Config file deployed to {destination_file}")
111
- except Exception as e:
112
- print(f"Error: Could not copy config file: {e}")
113
- sys.exit(1)
114
-
115
-
116
- backup_definition = BACKUP_DEFINITION.replace("@@HOME_DIR@@", os.path.expanduser("~"))
117
-
118
- try:
119
- with open(os.path.join(CONFIG_DIR, "backup.d", "default"), "w") as f:
120
- f.write(backup_definition)
121
- print(f"Default backup definition file deployed to {os.path.join(CONFIG_DIR, 'backup.d', 'default')}")
122
- except Exception as e:
123
- print(f"Error: Could not write default backup definition: {e}")
124
- sys.exit(1)
125
- except Exception as e:
126
- print(f"Installation failed: {e}")
127
- sys.exit(1)
128
-
129
- print("1. Now run `manager --create` to create the catalog database.")
130
- print("2. Then you can run `dar-backup --full-backup` to create a backup.")
131
- print("3. List backups with `dar-backup --list`")
132
- print("4. List contents of a backup with `dar-backup --list-contents <backup-name>`")
133
-
134
- sys.exit(0)
54
+ run_installer(args.config, args.create_db)
135
55
 
136
56
 
137
57
  if __name__ == "__main__":
138
- main()
58
+ installer_main()
dar_backup/manager.py CHANGED
@@ -36,6 +36,8 @@ from dar_backup.util import setup_logging
36
36
  from dar_backup.util import CommandResult
37
37
  from dar_backup.util import get_logger
38
38
  from dar_backup.util import get_binary_info
39
+ from dar_backup.util import show_version
40
+ from dar_backup.util import print_aligned_settings
39
41
 
40
42
  from dar_backup.command_runner import CommandRunner
41
43
  from dar_backup.command_runner import CommandResult
@@ -43,7 +45,7 @@ from dar_backup.util import backup_definition_completer, list_archive_completer,
43
45
 
44
46
  from datetime import datetime
45
47
  from time import time
46
- from typing import Dict, List, NamedTuple
48
+ from typing import Dict, List, NamedTuple, Tuple
47
49
 
48
50
  # Constants
49
51
  SCRIPTNAME = os.path.basename(__file__)
@@ -54,6 +56,15 @@ DB_SUFFIX = ".db"
54
56
  logger = None
55
57
  runner = None
56
58
 
59
+
60
+ def get_db_dir(config_settings: ConfigSettings) -> str:
61
+ """
62
+ Return the correct directory for storing catalog databases.
63
+ Uses manager_db_dir if set, otherwise falls back to backup_dir.
64
+ """
65
+ return getattr(config_settings, "manager_db_dir", None) or config_settings.backup_dir
66
+
67
+
57
68
  def show_more_help():
58
69
  help_text = f"""
59
70
  NAME
@@ -62,31 +73,43 @@ NAME
62
73
  print(help_text)
63
74
 
64
75
 
65
- def create_db(backup_def: str, config_settings: ConfigSettings):
76
+ def create_db(backup_def: str, config_settings: ConfigSettings, logger, runner) -> int:
77
+ db_dir = get_db_dir(config_settings)
78
+
79
+ if not os.path.exists(db_dir):
80
+ logger.error(f"DB dir does not exist: {db_dir}")
81
+ return 1
82
+ if not os.path.isdir(db_dir):
83
+ logger.error(f"DB path exists but is not a directory: {db_dir}")
84
+ return 1
85
+ if not os.access(db_dir, os.W_OK):
86
+ logger.error(f"DB dir is not writable: {db_dir}")
87
+ return 1
88
+
66
89
  database = f"{backup_def}{DB_SUFFIX}"
67
-
68
- database_path = os.path.join(config_settings.backup_dir, database)
69
-
70
- logger.debug(f"BACKUP_DIR: {config_settings.backup_dir}")
90
+ database_path = os.path.join(db_dir, database)
91
+
92
+ logger.debug(f"DB directory: {db_dir}")
71
93
 
72
94
  if os.path.exists(database_path):
73
- logger.warning(f'"{database_path}" already exists, skipping creation')
95
+ logger.info(f'"{database_path}" already exists, skipping creation')
74
96
  return 0
75
97
  else:
76
98
  logger.info(f'Create catalog database: "{database_path}"')
77
- command = ['dar_manager', '--create' , database_path]
99
+ command = ['dar_manager', '--create', database_path]
78
100
  process = runner.run(command)
79
101
  logger.debug(f"return code from 'db created': {process.returncode}")
80
102
  if process.returncode == 0:
81
103
  logger.info(f'Database created: "{database_path}"')
82
104
  else:
83
105
  logger.error(f'Something went wrong creating the database: "{database_path}"')
84
- stdout, stderr = process.stdout, process.stderr
106
+ stdout, stderr = process.stdout, process.stderr
85
107
  logger.error(f"stderr: {stderr}")
86
108
  logger.error(f"stdout: {stdout}")
87
109
 
88
110
  return process.returncode
89
111
 
112
+
90
113
  def list_catalogs(backup_def: str, config_settings: ConfigSettings, suppress_output=False) -> CommandResult:
91
114
  """
92
115
  List catalogs from the database for the given backup definition.
@@ -95,7 +118,7 @@ def list_catalogs(backup_def: str, config_settings: ConfigSettings, suppress_out
95
118
  A CommandResult containing the raw stdout/stderr and return code.
96
119
  """
97
120
  database = f"{backup_def}{DB_SUFFIX}"
98
- database_path = os.path.join(config_settings.backup_dir, database)
121
+ database_path = os.path.join(get_db_dir(config_settings), database)
99
122
 
100
123
  if not os.path.exists(database_path):
101
124
  error_msg = f'Database not found: "{database_path}"'
@@ -174,7 +197,7 @@ def list_archive_contents(archive: str, config_settings: ConfigSettings) -> int:
174
197
  """
175
198
  backup_def = backup_def_from_archive(archive)
176
199
  database = f"{backup_def}{DB_SUFFIX}"
177
- database_path = os.path.join(config_settings.backup_dir, database)
200
+ database_path = os.path.join(get_db_dir(config_settings), database)
178
201
 
179
202
  if not os.path.exists(database_path):
180
203
  logger.error(f'Database not found: "{database_path}"')
@@ -217,8 +240,9 @@ def list_catalog_contents(catalog_number: int, backup_def: str, config_settings:
217
240
  """
218
241
  List the contents of catalog # in catalog database for given backup definition
219
242
  """
243
+ logger = get_logger()
220
244
  database = f"{backup_def}{DB_SUFFIX}"
221
- database_path = os.path.join(config_settings.backup_dir, database)
245
+ database_path = os.path.join(get_db_dir(config_settings), database)
222
246
  if not os.path.exists(database_path):
223
247
  logger.error(f'Catalog database not found: "{database_path}"')
224
248
  return 1
@@ -239,7 +263,7 @@ def find_file(file, backup_def, config_settings):
239
263
  Find a specific file
240
264
  """
241
265
  database = f"{backup_def}{DB_SUFFIX}"
242
- database_path = os.path.join(config_settings.backup_dir, database)
266
+ database_path = os.path.join(get_db_dir(config_settings), database)
243
267
  if not os.path.exists(database_path):
244
268
  logger.error(f'Database not found: "{database_path}"')
245
269
  return 1
@@ -283,7 +307,7 @@ def add_specific_archive(archive: str, config_settings: ConfigSettings, director
283
307
 
284
308
  # Determine catalog DB path
285
309
  database = f"{backup_definition}{DB_SUFFIX}"
286
- database_path = os.path.realpath(os.path.join(config_settings.backup_dir, database))
310
+ database_path = os.path.realpath(os.path.join(get_db_dir(config_settings), database))
287
311
 
288
312
  # Safety check: is archive older than latest in catalog?
289
313
  try:
@@ -444,7 +468,7 @@ def remove_specific_archive(archive: str, config_settings: ConfigSettings) -> in
444
468
 
445
469
  """
446
470
  backup_def = backup_def_from_archive(archive)
447
- database_path = os.path.join(config_settings.backup_dir, f"{backup_def}{DB_SUFFIX}")
471
+ database_path = os.path.join(get_db_dir(config_settings), f"{backup_def}{DB_SUFFIX}")
448
472
  cat_no:int = cat_no_for_name(archive, config_settings)
449
473
  if cat_no >= 0:
450
474
  command = ['dar_manager', '--base', database_path, "--delete", str(cat_no)]
@@ -484,8 +508,6 @@ def build_arg_parser():
484
508
  return parser
485
509
 
486
510
 
487
-
488
-
489
511
  def main():
490
512
  global logger, runner
491
513
 
@@ -508,16 +530,12 @@ def main():
508
530
  return
509
531
 
510
532
  if args.version:
511
- print(f"{SCRIPTNAME} {about.__version__}")
512
- print(f"Source code is here: https://github.com/per2jensen/dar-backup")
513
- print('''Licensed under GNU GENERAL PUBLIC LICENSE v3, see the supplied file "LICENSE" for details.
514
- THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW, not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
515
- See section 15 and section 16 in the supplied "LICENSE" file.''')
533
+ show_version()
516
534
  sys.exit(0)
517
- return
518
535
 
519
536
  args.config_file = os.path.expanduser(os.path.expandvars(args.config_file))
520
537
  config_settings = ConfigSettings(args.config_file)
538
+
521
539
  if not os.path.dirname(config_settings.logfile_location):
522
540
  print(f"Directory for log file '{config_settings.logfile_location}' does not exist, exiting")
523
541
  sys.exit(1)
@@ -528,15 +546,23 @@ See section 15 and section 16 in the supplied "LICENSE" file.''')
528
546
  command_logger = get_logger(command_output_logger=True)
529
547
  runner = CommandRunner(logger=logger, command_logger=command_logger)
530
548
 
549
+ start_msgs: List[Tuple[str, str]] = []
550
+
531
551
  start_time = int(time())
532
- logger.info(f"{SCRIPTNAME} started, version: {about.__version__}")
552
+ start_msgs.append((f"{SCRIPTNAME}:", about.__version__))
533
553
  logger.info(f"START TIME: {start_time}")
534
554
  logger.debug(f"`args`:\n{args}")
535
555
  logger.debug(f"`config_settings`:\n{config_settings}")
556
+ start_msgs.append(("Config file:", args.config_file))
557
+ args.verbose and start_msgs.append(("Backup dir:", config_settings.backup_dir))
558
+ start_msgs.append(("Logfile:", config_settings.logfile_location))
559
+ args.verbose and start_msgs.append(("--alternate-archive-dir:", args.alternate_archive_dir))
560
+ args.verbose and start_msgs.append(("--cleanup-specific-archives:", args.cleanup_specific_archives))
536
561
  dar_manager_properties = get_binary_info(command='dar_manager')
537
- logger.debug(f"dar_manager location: {dar_manager_properties['path']}")
538
- logger.debug(f"dar_manager version: {dar_manager_properties['version']}")
562
+ start_msgs.append(("dar_manager:", dar_manager_properties['path']))
563
+ start_msgs.append(("dar_manager v.:", dar_manager_properties['version']))
539
564
 
565
+ print_aligned_settings(start_msgs)
540
566
 
541
567
  # --- Sanity checks ---
542
568
  if args.add_dir and not args.add_dir.strip():
@@ -597,14 +623,14 @@ See section 15 and section 16 in the supplied "LICENSE" file.''')
597
623
  # --- Functional logic ---
598
624
  if args.create_db:
599
625
  if args.backup_def:
600
- sys.exit(create_db(args.backup_def, config_settings))
626
+ sys.exit(create_db(args.backup_def, config_settings, logger, runner))
601
627
  return
602
628
  else:
603
629
  for root, dirs, files in os.walk(config_settings.backup_d_dir):
604
630
  for file in files:
605
631
  current_backupdef = os.path.basename(file)
606
632
  logger.debug(f"Create catalog db for backup definition: '{current_backupdef}'")
607
- result = create_db(current_backupdef, config_settings)
633
+ result = create_db(current_backupdef, config_settings, logger, runner)
608
634
  if result != 0:
609
635
  sys.exit(result)
610
636
  return
dar_backup/util.py CHANGED
@@ -24,8 +24,11 @@ from datetime import datetime
24
24
  from dar_backup.config_settings import ConfigSettings
25
25
  import dar_backup.__about__ as about
26
26
 
27
- from typing import NamedTuple, List
27
+ from rich.console import Console
28
+ from rich.text import Text
28
29
 
30
+ from typing import NamedTuple, List
31
+ from typing import Tuple
29
32
 
30
33
 
31
34
  logger=None
@@ -100,6 +103,30 @@ def get_logger(command_output_logger: bool = False) -> logging.Logger:
100
103
  return secondary_logger if command_output_logger else logger
101
104
 
102
105
 
106
+ # Setup completer logger only once
107
+ def _setup_completer_logger(logfile="/tmp/dar_backup_completer.log"):
108
+ logger = logging.getLogger("completer")
109
+ if not logger.handlers:
110
+ handler = logging.FileHandler(logfile)
111
+ formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
112
+ handler.setFormatter(formatter)
113
+ logger.addHandler(handler)
114
+ logger.setLevel(logging.DEBUG)
115
+ return logger
116
+
117
+ # Singleton logger for completer debugging
118
+ completer_logger = _setup_completer_logger()
119
+ completer_logger.debug("Completer logger initialized.")
120
+
121
+
122
+
123
+ def show_version():
124
+ script_name = os.path.basename(sys.argv[0])
125
+ print(f"{script_name} {about.__version__}")
126
+ print(f"{script_name} source code is here: https://github.com/per2jensen/dar-backup")
127
+ print('''Licensed under GNU GENERAL PUBLIC LICENSE v3, see the supplied file "LICENSE" for details.
128
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW, not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
129
+ See section 15 and section 16 in the supplied "LICENSE" file.''')
103
130
 
104
131
  def extract_version(output):
105
132
  match = re.search(r'(\d+\.\d+(\.\d+)?)', output)
@@ -218,7 +245,6 @@ class RestoreError(Exception):
218
245
  pass
219
246
 
220
247
 
221
-
222
248
  class CommandResult(NamedTuple):
223
249
  """
224
250
  The reult of the run_command() function.
@@ -338,6 +364,7 @@ def extract_backup_definition_fallback() -> str:
338
364
 
339
365
 
340
366
 
367
+
341
368
  def list_archive_completer(prefix, parsed_args, **kwargs):
342
369
  import os
343
370
  import configparser
@@ -361,12 +388,39 @@ def list_archive_completer(prefix, parsed_args, **kwargs):
361
388
  files = os.listdir(backup_dir)
362
389
  archive_re = re.compile(rf"^{re.escape(backup_def)}_.+_\d{{4}}-\d{{2}}-\d{{2}}\.1\.dar$") if backup_def else re.compile(r".+_\d{4}-\d{2}-\d{2}\.1\.dar$")
363
390
 
364
- return [
391
+ completions = [
365
392
  f.rsplit(".1.dar", 1)[0]
366
393
  for f in files
367
394
  if archive_re.match(f)
368
395
  ]
369
396
 
397
+ completions = sorted(set(completions), key=sort_key)
398
+ return completions or ["[no matching archives]"]
399
+
400
+
401
+
402
+ def sort_key(archive_name: str):
403
+ """
404
+ Sort by backup definition and then by date extracted from the archive name.
405
+ Handles formats like: <def>_<TYPE>_<YYYY-MM-DD>.<N>.dar
406
+ """
407
+ try:
408
+ base = archive_name.split('.')[0] # remove .1.dar
409
+ parts = base.split('_')
410
+ if len(parts) < 3:
411
+ return (archive_name, datetime.min) # fallback for non-matching formats
412
+
413
+ # Correct assumption: last two parts are TYPE and DATE
414
+ def_name = '_'.join(parts[:-2]) # everything before _TYPE_DATE
415
+ date_str = parts[-1]
416
+ date = datetime.strptime(date_str, "%Y-%m-%d")
417
+ completer_logger.debug(f"Archive: {archive_name}, Def: {def_name}, Date: {date}")
418
+ return (def_name, date)
419
+ except Exception:
420
+ return (archive_name, datetime.min)
421
+
422
+
423
+
370
424
 
371
425
  def archive_content_completer(prefix, parsed_args, **kwargs):
372
426
  """
@@ -384,14 +438,15 @@ def archive_content_completer(prefix, parsed_args, **kwargs):
384
438
  # Expand config path
385
439
  config_file = expand_path(getattr(parsed_args, "config_file", "~/.config/dar-backup/dar-backup.conf"))
386
440
  config = ConfigSettings(config_file=config_file)
387
- backup_dir = config.backup_dir
441
+ #db_dir = expand_path((getattr(config, 'manager_db_dir', config.backup_dir))) # use manager_db_dir if set, else backup_dir
442
+ db_dir = expand_path(getattr(config, 'manager_db_dir', None) or config.backup_dir)
388
443
 
389
444
  # Which db files to inspect?
390
445
  backup_def = getattr(parsed_args, "backup_def", None)
391
446
  db_files = (
392
- [os.path.join(backup_dir, f"{backup_def}.db")]
447
+ [os.path.join( db_dir, f"{backup_def}.db")]
393
448
  if backup_def
394
- else [os.path.join(backup_dir, f) for f in os.listdir(backup_dir) if f.endswith(".db")]
449
+ else [os.path.join( db_dir, f) for f in os.listdir( db_dir) if f.endswith(".db")]
395
450
  )
396
451
 
397
452
  completions = []
@@ -418,13 +473,6 @@ def archive_content_completer(prefix, parsed_args, **kwargs):
418
473
  if archive.startswith(prefix):
419
474
  completions.append(archive)
420
475
 
421
- # Sort: first by name (before first '_'), then by date (YYYY-MM-DD)
422
- def sort_key(archive):
423
- name_part = archive.split("_")[0]
424
- date_match = re.search(r"\d{4}-\d{2}-\d{2}", archive)
425
- date = datetime.strptime(date_match.group(0), "%Y-%m-%d") if date_match else datetime.min
426
- return (name_part, date)
427
-
428
476
  completions = sorted(set(completions), key=sort_key)
429
477
  return completions or ["[no matching archives]"]
430
478
 
@@ -444,6 +492,8 @@ def add_specific_archive_completer(prefix, parsed_args, **kwargs):
444
492
 
445
493
  config_file = expand_path(getattr(parsed_args, "config_file", "~/.config/dar-backup/dar-backup.conf"))
446
494
  config = ConfigSettings(config_file=config_file)
495
+ #db_dir = expand_path((getattr(config, 'manager_db_dir', config.backup_dir))) # use manager_db_dir if set, else backup_dir
496
+ db_dir = expand_path(getattr(config, 'manager_db_dir') or config.backup_dir)
447
497
  backup_dir = config.backup_dir
448
498
  backup_def = getattr(parsed_args, "backup_def", None)
449
499
 
@@ -461,7 +511,7 @@ def add_specific_archive_completer(prefix, parsed_args, **kwargs):
461
511
  all_archives.add(base)
462
512
 
463
513
  # Step 2: exclude ones already present in the .db
464
- db_path = os.path.join(backup_dir, f"{backup_def}.db") if backup_def else None
514
+ db_path = os.path.join(db_dir, f"{backup_def}.db") if backup_def else None
465
515
  existing = set()
466
516
 
467
517
  if db_path and os.path.exists(db_path):
@@ -484,3 +534,92 @@ def add_specific_archive_completer(prefix, parsed_args, **kwargs):
484
534
  candidates = sorted(archive for archive in all_archives if archive not in existing)
485
535
  return candidates or ["[no new archives]"]
486
536
 
537
+
538
+
539
+
540
+ def patch_config_file(path: str, replacements: dict) -> None:
541
+ """
542
+ Replace specific key values in a config file in-place.
543
+
544
+ Args:
545
+ path: Path to the config file.
546
+ replacements: Dictionary of keys to new values, e.g., {"LOGFILE_LOCATION": "/tmp/logfile.log"}.
547
+ """
548
+ with open(path, 'r') as f:
549
+ lines = f.readlines()
550
+
551
+ with open(path, 'w') as f:
552
+ for line in lines:
553
+ key = line.split('=')[0].strip()
554
+ if key in replacements:
555
+ f.write(f"{key} = {replacements[key]}\n")
556
+ else:
557
+ f.write(line)
558
+
559
+
560
+
561
+
562
+ console = Console()
563
+
564
+ def print_aligned_settings(
565
+ settings: List[Tuple[str, str]],
566
+ log: bool = True,
567
+ header: str = "Startup Settings",
568
+ highlight_keywords: List[str] = None
569
+ ) -> None:
570
+ """
571
+ Print and optionally log settings nicely, using rich for color.
572
+ Highlights settings if dangerous keywords are found inside label or text,
573
+ but only if text is not None or empty.
574
+ """
575
+ if not settings:
576
+ return
577
+
578
+ settings = [(str(label), "" if text is None else str(text)) for label, text in settings]
579
+ logger = get_logger()
580
+
581
+ max_label_length = max(len(label) for label, _ in settings)
582
+
583
+ header_line = f"========== {header} =========="
584
+ footer_line = "=" * len(header_line)
585
+
586
+ console.print(f"[bold cyan]{header_line}[/bold cyan]")
587
+ if log and logger:
588
+ logger.info(header_line)
589
+
590
+ for label, text in settings:
591
+ padded_label = f"{label:<{max_label_length}}"
592
+
593
+ label_clean = label.rstrip(":").lower()
594
+ text_clean = text.lower()
595
+
596
+ # Skip highlighting if text is empty
597
+ if not text_clean.strip():
598
+ danger = False
599
+ else:
600
+ danger = False
601
+ if highlight_keywords:
602
+ combined_text = f"{label_clean} {text_clean}"
603
+ danger = any(keyword.lower() in combined_text for keyword in highlight_keywords)
604
+
605
+ # Build the line
606
+ line_text = Text()
607
+ line_text.append(padded_label, style="bold")
608
+ line_text.append(" ", style="none")
609
+
610
+ if danger:
611
+ line_text.append("[!]", style="bold red")
612
+ line_text.append(" ", style="none")
613
+
614
+ line_text.append(text, style="white")
615
+
616
+ console.print(line_text)
617
+
618
+ # Always log clean text (no [!] in log)
619
+ final_line_for_log = f"{padded_label} {text}"
620
+ if log and logger:
621
+ logger.info(final_line_for_log)
622
+
623
+ console.print(f"[bold cyan]{footer_line}[/bold cyan]")
624
+ if log and logger:
625
+ logger.info(footer_line)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dar-backup
3
- Version: 0.6.19
3
+ Version: 0.6.20.1
4
4
  Summary: A script to do full, differential and incremental backups using dar. Some files are restored from the backups during verification, after which par2 redundancy files are created. The script also has a cleanup feature to remove old backups and par2 files.
5
5
  Project-URL: GPG Public Key, https://keys.openpgp.org/search?q=dar-backup@pm.me
6
6
  Project-URL: Homepage, https://github.com/per2jensen/dar-backup/tree/main/v2
@@ -0,0 +1,23 @@
1
+ dar_backup/.darrc,sha256=-aerqivZmOsW_XBCh9IfbYTUvw0GkzDSr3Vx4GcNB1g,2113
2
+ dar_backup/Changelog.md,sha256=D993XIep6ECnK42XdH6ayE8RlXDJVCbpr-SeCqEFRH8,9448
3
+ dar_backup/README.md,sha256=PcZQRaBw9i_GbRoA786OFKI0PwY87E7D30UzovWqEQQ,44902
4
+ dar_backup/__about__.py,sha256=rQMac36Uw_nIjFumz9vuaDITRsJHLv1bbxAZYx-Ka4E,24
5
+ dar_backup/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ dar_backup/clean_log.py,sha256=uQ_9HomK7RRkskXrgMPbAC84RgNlCFJ8RHL9KHeAMvc,5447
7
+ dar_backup/cleanup.py,sha256=4IG736gdmvjurcQQ_DC-B5CoA3Ix9u5C3WfwmMmFIpc,12500
8
+ dar_backup/command_runner.py,sha256=PQA968EXssSGjSs_16psFkdxRZi9-YK4TrBKFz0ss3k,4455
9
+ dar_backup/config_settings.py,sha256=8HhIDVtFk7D3VqJlYcveOlAaSEJwIaO3MICz-rN3tVY,5260
10
+ dar_backup/dar-backup.conf,sha256=WWNrysjQ1ii2jpab8jxgWCw3IkNxLBYVOW8fxWbO_9g,1155
11
+ dar_backup/dar_backup.py,sha256=x0at94NeQzu8tv2ohg3YKAt6j76gQUS2387wh5Z4B78,41856
12
+ dar_backup/dar_backup_systemd.py,sha256=oehD_t9CFu0CsMgDWRH-Gt74Ynl1m29yqQEh5Kxv7aw,3807
13
+ dar_backup/demo.py,sha256=fl2LHHWxuXRT814M_zuZh_YqqLk6nuNg9BI9HpLzdUU,4841
14
+ dar_backup/exceptions.py,sha256=6fpHpnhkbtFcFZVDMqCsdESg-kQNCjF40EROGKeq8yU,113
15
+ dar_backup/installer.py,sha256=gISJ3inz1uTjM5235gNtwjDUG-Fq6b3OZzMQuJl5eRg,2252
16
+ dar_backup/manager.py,sha256=JL-JX2C6RiDIj64Gw9pxNLUpxFqij1OBPp1BaUvV5sM,26573
17
+ dar_backup/rich_progress.py,sha256=jTwM-4VlqHHzKqIfyXjL1pWEriobSJwCdN3YXzXzRdo,3105
18
+ dar_backup/util.py,sha256=u_l6Hti6ZAHxaOqTZlSWBDspoKmkdoyzfZjhQgJqyOQ,21643
19
+ dar_backup-0.6.20.1.dist-info/METADATA,sha256=bD9ou-yK2zva5xz8vPVd57xqkEsjN9OaNAWZeoxDajE,86626
20
+ dar_backup-0.6.20.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
21
+ dar_backup-0.6.20.1.dist-info/entry_points.txt,sha256=jFMqGdvGO8NeHrmcB0pxY7__PCSQAWRCFzsschMXsec,248
22
+ dar_backup-0.6.20.1.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
23
+ dar_backup-0.6.20.1.dist-info/RECORD,,
@@ -3,5 +3,5 @@ clean-log = dar_backup.clean_log:main
3
3
  cleanup = dar_backup.cleanup:main
4
4
  dar-backup = dar_backup.dar_backup:main
5
5
  dar-backup-systemd = dar_backup.dar_backup_systemd:main
6
- installer = dar_backup.installer:main
6
+ demo = dar_backup.demo:main
7
7
  manager = dar_backup.manager:main
@@ -1,21 +0,0 @@
1
- dar_backup/.darrc,sha256=-aerqivZmOsW_XBCh9IfbYTUvw0GkzDSr3Vx4GcNB1g,2113
2
- dar_backup/Changelog.md,sha256=PbR2JupEHiWdRNP8ApuWEYIrRQEeJQUnlkVR7N4JJHg,8591
3
- dar_backup/README.md,sha256=PcZQRaBw9i_GbRoA786OFKI0PwY87E7D30UzovWqEQQ,44902
4
- dar_backup/__about__.py,sha256=1xdEgfhhUWpssnr_9DTiORat5DDv6NUXRtydGkm-Aog,22
5
- dar_backup/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
- dar_backup/clean_log.py,sha256=uQ_9HomK7RRkskXrgMPbAC84RgNlCFJ8RHL9KHeAMvc,5447
7
- dar_backup/cleanup.py,sha256=mrpu9xAy6AKcEbjMetscEh-IzJp0xVFObdrwaXncFtA,12712
8
- dar_backup/command_runner.py,sha256=PQA968EXssSGjSs_16psFkdxRZi9-YK4TrBKFz0ss3k,4455
9
- dar_backup/config_settings.py,sha256=Rh4T35-w_5tpRAViMfv3YP3GBpG4mQy7Do8cNBzYAR0,4912
10
- dar_backup/dar-backup.conf,sha256=64O3bGlzqupneT2gVeaETJ1qS6-3Exet9Zto27jgwPQ,897
11
- dar_backup/dar_backup.py,sha256=Uy0VAFRbOAmZCyZyyEfWuIM4he2WivfQXhKzVGhWbHc,41939
12
- dar_backup/dar_backup_systemd.py,sha256=oehD_t9CFu0CsMgDWRH-Gt74Ynl1m29yqQEh5Kxv7aw,3807
13
- dar_backup/installer.py,sha256=fl2LHHWxuXRT814M_zuZh_YqqLk6nuNg9BI9HpLzdUU,4841
14
- dar_backup/manager.py,sha256=t-CWxvxzhMHCOCU99px6heL3dRmil2JDOyw9PHL96xY,25712
15
- dar_backup/rich_progress.py,sha256=jTwM-4VlqHHzKqIfyXjL1pWEriobSJwCdN3YXzXzRdo,3105
16
- dar_backup/util.py,sha256=REZANvqLqczx3MvGszpVaesrpW03NuH_GPGjDllpT4Q,16877
17
- dar_backup-0.6.19.dist-info/METADATA,sha256=jQwLE815tTEXjKh7l0rUd5SprMF_czZ3QPRXn8TNMnM,86624
18
- dar_backup-0.6.19.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
19
- dar_backup-0.6.19.dist-info/entry_points.txt,sha256=NSCYoG5Dvh1UhvKWOQPgcHdFv4--R4Sre3d9FwJra3E,258
20
- dar_backup-0.6.19.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
21
- dar_backup-0.6.19.dist-info/RECORD,,