dar-backup 0.6.18__py3-none-any.whl → 0.6.20__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,37 @@
1
1
  <!-- markdownlint-disable MD024 -->
2
2
  # dar-backup Changelog
3
3
 
4
+ ## v2-beta-0.6.20 - 2025-05-03
5
+
6
+ Github link: [v2-beta-0.6.20](https://github.com/per2jensen/dar-backup/tree/v2-beta-0.6.20/v2)
7
+
8
+ ### Added
9
+
10
+ - show_version() moved to util and tests for dar-backup, manager and cleanup
11
+ - startup informational messages now works the same across the scripts
12
+ - Improved ConfigSettings class to handle optional configuration keys
13
+
14
+ -- test cases added
15
+
16
+ - Optional config parameter: MANAGER_DB_DIR, ideally to point to another disk for safe keeping backup catalogs
17
+
18
+ -- test cases added
19
+
20
+ ## v2-beta-0.6.19 - 2025-04-21
21
+
22
+ Github link: [v2-beta-0.6.19](https://github.com/per2jensen/dar-backup/tree/v2-beta-0.6.19/v2)
23
+
24
+ ### Added
25
+
26
+ - removed a BackupError in the verify() to reduce noise in logs and let the rest of "compares" run.
27
+ - Added bash and zsh auto completion for a nicer CLI experience.
28
+
29
+ -- See [README for details](https://github.com/per2jensen/dar-backup?tab=readme-ov-file#shell-autocompletion)
30
+
31
+ - Improvement to command_runner.run(), more robust decoding
32
+
33
+ - Manager --add-specific-archive now gives a prompt with a warning if user attempts to add a catalog that breaks chronology. The user is allowed to go forward and ignore the warning or can choose to abort. The program times out after a little while and discards the operation.
34
+
4
35
  ## v2-beta-0.6.18 - 2025-04-05
5
36
 
6
37
  Github link: [v2-beta-0.6.18](https://github.com/per2jensen/dar-backup/tree/v2-beta-0.6.18/v2)
dar_backup/README.md CHANGED
@@ -2,12 +2,18 @@
2
2
  # Full, differential or incremental backups using 'dar'
3
3
 
4
4
  [![codecov](https://codecov.io/gh/per2jensen/dar-backup/branch/main/graph/badge.svg)](https://codecov.io/gh/per2jensen/dar-backup)
5
+ [![PyPI monthly downloads](https://img.shields.io/pypi/dm/dar-backup)](https://pypi.org/project/dar-backup/)
6
+ [![Total Downloads](https://img.shields.io/badge/dynamic/json?color=blue&label=Total%20Downloads&query=total&url=https%3A%2F%2Fraw.githubusercontent.com%2Fper2jensen%2Fdar-backup%2Fmain%2Fdownloads.json)](https://pypi.org/project/dar-backup/)
5
7
 
6
8
  The wonderful 'dar' [Disk Archiver](https://github.com/Edrusb/DAR) is used for
7
9
  the heavy lifting, together with the par2 suite in these scripts.
8
10
 
9
11
  This is the `Python` based **version 2** of `dar-backup`.
10
12
 
13
+ ## TL;DR
14
+
15
+ `dar-backup` is a Python-powered CLI for creating and validating full, differential, and incremental backups using dar and par2. Designed for long-term restore integrity, even on user-space filesystems like FUSE.
16
+
11
17
  ## Table of Contents
12
18
 
13
19
  - [Full, differential or incremental backups using 'dar'](#full-differential-or-incremental-backups-using-dar)
@@ -33,6 +39,7 @@ This is the `Python` based **version 2** of `dar-backup`.
33
39
  - [Generate systemd files](#generate-systemd-files)
34
40
  - [Service: dar-back --incremental-backup](#service-dar-backup---incremental-backup)
35
41
  - [Timer: dar-back --incremental-backup](#timer-dar-backup---incremental-backup)
42
+ - [Systemd timer note](#systemd-timer-note)
36
43
  - [List contents of an archive](#list-contents-of-an-archive)
37
44
  - [dar file selection examples](#dar-file-selection-examples)
38
45
  - [Select a directory](#select-a-directory)
@@ -58,7 +65,9 @@ This is the `Python` based **version 2** of `dar-backup`.
58
65
  - [Skipping cache directories](#skipping-cache-directories)
59
66
  - [Progress bar + current directory](#progress-bar-and-current-directory)
60
67
  - [Todo](#todo)
68
+ - [Known Limitations / Edge Cases](#known-limitations--edge-cases)
61
69
  - [Reference](#reference)
70
+ - [CLI Tools Overview](#cli-tools-overview)
62
71
  - [Test coverage report](#test-coverage)
63
72
  - [dar-backup](#dar-backup-options)
64
73
  - [manager](#manager-options)
@@ -651,6 +660,10 @@ Persistent=true
651
660
  WantedBy=timers.target
652
661
  ````
653
662
 
663
+ ## systemd timer note
664
+
665
+ 📅 OnCalendar syntax is flexible — you can tweak backup schedules easily. Run systemd-analyze calendar to preview timers.
666
+
654
667
  ## list contents of an archive
655
668
 
656
669
  ```` bash
@@ -941,8 +954,27 @@ The indicators are not shown if dar-backup is run from systemd or if it is used
941
954
  - Look into a way to move the .par2 files away from the `dar` slices, to maximize chance of good redundancy.
942
955
  - Add option to dar-backup to use the `dar` option `--fsa-scope none`
943
956
 
957
+ ## Known Limitations / Edge Cases
958
+
959
+ Does not currently encrypt data (by design — relies on encrypted storage)
960
+
961
+ One backup definition per file
962
+
963
+ .par2 files created for each slice (may be moved in future)
964
+
944
965
  ## Reference
945
966
 
967
+ ### CLI Tools Overview
968
+
969
+ | Command | Description |
970
+ |-----------------------|-------------------------------------------|
971
+ | `dar-backup` | Perform full, differential, or incremental backups with verification and restore testing |
972
+ | `manager` | Maintain and query catalog databases for archives |
973
+ | `cleanup` | Remove outdated DIFF/INCR archives (and optionally FULLs) |
974
+ | `clean-log` | Clean up excessive log output from dar command logs |
975
+ | `installer` | Set up required directories and default config files |
976
+ | `dar-backup-systemd` | Generate (and optionally install) systemd timers and services for automated backups |
977
+
946
978
  ### test coverage
947
979
 
948
980
  Running
@@ -951,23 +983,26 @@ Running
951
983
  pytest --cov=dar_backup tests/
952
984
  ````
953
985
 
954
- results for version 0.6.17 in this report:
986
+ results for a dev version 0.6.19 in this report:
955
987
 
956
988
  ```` code
957
989
  ---------- coverage: platform linux, python 3.12.3-final-0 -----------
958
- Name Stmts Miss Cover
959
- -------------------------------------------------------------------------------------
960
- venv/lib/python3.12/site-packages/dar_backup/__about__.py 1 0 100%
961
- venv/lib/python3.12/site-packages/dar_backup/__init__.py 0 0 100%
962
- venv/lib/python3.12/site-packages/dar_backup/clean_log.py 68 14 79%
963
- venv/lib/python3.12/site-packages/dar_backup/cleanup.py 196 53 73%
964
- venv/lib/python3.12/site-packages/dar_backup/config_settings.py 66 8 88%
965
- venv/lib/python3.12/site-packages/dar_backup/dar_backup.py 464 99 79%
966
- venv/lib/python3.12/site-packages/dar_backup/installer.py 46 46 0%
967
- venv/lib/python3.12/site-packages/dar_backup/manager.py 316 72 77%
968
- venv/lib/python3.12/site-packages/dar_backup/util.py 162 34 79%
969
- -------------------------------------------------------------------------------------
970
- TOTAL 1319 326 75%
990
+ Name Stmts Miss Cover
991
+ ----------------------------------------------------------
992
+ src/dar_backup/__about__.py 1 0 100%
993
+ src/dar_backup/__init__.py 0 0 100%
994
+ src/dar_backup/clean_log.py 68 13 81%
995
+ src/dar_backup/cleanup.py 193 17 91%
996
+ src/dar_backup/command_runner.py 73 1 99%
997
+ src/dar_backup/config_settings.py 66 8 88%
998
+ src/dar_backup/dar_backup.py 535 56 90%
999
+ src/dar_backup/dar_backup_systemd.py 56 7 88%
1000
+ src/dar_backup/installer.py 59 6 90%
1001
+ src/dar_backup/manager.py 351 56 84%
1002
+ src/dar_backup/rich_progress.py 70 7 90%
1003
+ src/dar_backup/util.py 130 15 88%
1004
+ ----------------------------------------------------------
1005
+ TOTAL 1602 186 88%
971
1006
  ````
972
1007
 
973
1008
  ### dar-backup options
dar_backup/__about__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.6.18"
1
+ __version__ = "0.6.20"
dar_backup/clean_log.py CHANGED
@@ -11,6 +11,8 @@ See section 15 and section 16 in the supplied "LICENSE" file
11
11
 
12
12
  This script can be used to remove (much of) the logged output from `dar`.
13
13
  When `dar` verbose options are enabled, quite a lot of information is emitted.
14
+
15
+ If a rerex is matched, the entire line is removed (change in v2-beta-0.6.19).
14
16
  """
15
17
 
16
18
 
@@ -19,7 +21,7 @@ import re
19
21
  import os
20
22
  import sys
21
23
 
22
- from . import __about__ as about
24
+ from dar_backup import __about__ as about
23
25
  from dar_backup.config_settings import ConfigSettings
24
26
 
25
27
  LICENSE = '''Licensed under GNU GENERAL PUBLIC LICENSE v3, see the supplied file "LICENSE" for details.
@@ -32,7 +34,7 @@ def clean_log_file(log_file_path, dry_run=False):
32
34
 
33
35
  if not os.path.isfile(log_file_path):
34
36
  print(f"File '{log_file_path}' not found!")
35
- sys.exit(1)
37
+ sys.exit(127)
36
38
 
37
39
  if not os.access(log_file_path, os.R_OK):
38
40
  print(f"No read permission for '{log_file_path}'")
@@ -49,13 +51,18 @@ def clean_log_file(log_file_path, dry_run=False):
49
51
  temp_file_path = log_file_path + ".tmp"
50
52
 
51
53
  patterns = [
54
+ r"INFO\s*-\s*Inspecting\s*directory",
55
+ r"INFO\s*-\s*Finished\s*Inspecting",
52
56
  r"INFO\s*-\s*<File",
57
+ r"INFO\s*-\s*</File",
53
58
  r"INFO\s*-\s*<Attributes",
59
+ r"INFO\s*-\s*</Attributes",
54
60
  r"INFO\s*-\s*</Directory",
55
61
  r"INFO\s*-\s*<Directory",
56
- r"INFO\s*-\s*</File",
57
- r"INFO\s*-\s*Inspecting\s*directory",
58
- r"INFO\s*-\s*Finished\s*Inspecting"
62
+ r"INFO\s*-\s*<Catalog",
63
+ r"INFO\s*-\s*</Catalog",
64
+ r"INFO\s*-\s*<Symlink",
65
+ r"INFO\s*-\s*</Symlink",
59
66
  ]
60
67
 
61
68
  try:
@@ -70,9 +77,9 @@ def clean_log_file(log_file_path, dry_run=False):
70
77
  if dry_run:
71
78
  print(f"Would remove: {original_line.strip()}") # Print full line for dry-run
72
79
  matched = True # Mark that a pattern matched
73
- line = re.sub(pattern, "", line).strip() # Remove only matched part
80
+ break # No need to check other patterns if one matches
74
81
 
75
- if not dry_run and line: # In normal mode, only write non-empty lines
82
+ if not dry_run and not matched: # In normal mode, only write non-empty lines
76
83
  outfile.write(line.rstrip() + "\n")
77
84
 
78
85
  if dry_run and matched:
dar_backup/cleanup.py CHANGED
@@ -13,17 +13,22 @@ This script removes old DIFF and INCR archives + accompanying .par2 files accord
13
13
  [AGE] settings in the configuration file.
14
14
  """
15
15
 
16
+ import argcomplete
16
17
  import argparse
17
18
  import logging
18
19
  import os
19
20
  import re
20
21
  import subprocess
21
22
  import sys
23
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")))
24
+
25
+
22
26
 
23
27
  from datetime import datetime, timedelta
24
28
  from inputimeout import inputimeout, TimeoutOccurred
25
29
  from time import time
26
- from typing import Dict, List, NamedTuple
30
+ from typing import Dict, List, NamedTuple, Tuple
31
+
27
32
 
28
33
  from . import __about__ as about
29
34
  from dar_backup.config_settings import ConfigSettings
@@ -31,6 +36,9 @@ from dar_backup.util import list_backups
31
36
  from dar_backup.util import setup_logging
32
37
  from dar_backup.util import get_logger
33
38
  from dar_backup.util import requirements
39
+ from dar_backup.util import show_version
40
+ from dar_backup.util import print_aligned_settings
41
+ from dar_backup.util import backup_definition_completer, list_archive_completer
34
42
 
35
43
  from dar_backup.command_runner import CommandRunner
36
44
  from dar_backup.command_runner import CommandResult
@@ -150,21 +158,12 @@ def delete_catalog(catalog_name: str, args: NamedTuple) -> bool:
150
158
  return False
151
159
 
152
160
 
153
- def show_version():
154
- script_name = os.path.basename(sys.argv[0])
155
- print(f"{script_name} {about.__version__}")
156
- print('''Licensed under GNU GENERAL PUBLIC LICENSE v3, see the supplied file "LICENSE" for details.
157
- THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW, not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
158
- See section 15 and section 16 in the supplied "LICENSE" file.''')
159
-
160
-
161
161
  def confirm_full_archive_deletion(archive_name: str, test_mode=False) -> bool:
162
162
  try:
163
163
  if test_mode:
164
- confirmation = os.environ.get("CLEANUP_TEST_DELETE_FULL")
165
- if confirmation is None:
166
- raise RuntimeError("envvar 'CLEANUP_TEST_DELETE_FULL' not set")
167
- 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"
168
167
  else:
169
168
  confirmation = inputimeout(
170
169
  prompt=f"Are you sure you want to delete the FULL archive '{archive_name}'? (yes/no): ",
@@ -186,16 +185,19 @@ def main():
186
185
  global logger, runner
187
186
 
188
187
  parser = argparse.ArgumentParser(description="Cleanup old archives according to AGE configuration.")
189
- parser.add_argument('-d', '--backup-definition', help="Specific backup definition to cleanup.")
188
+ parser.add_argument('-d', '--backup-definition', help="Specific backup definition to cleanup.").completer = backup_definition_completer
190
189
  parser.add_argument('-c', '--config-file', '-c', type=str, help="Path to 'dar-backup.conf'", default='~/.config/dar-backup/dar-backup.conf')
191
190
  parser.add_argument('-v', '--version', action='store_true', help="Show version information.")
192
191
  parser.add_argument('--alternate-archive-dir', type=str, help="Cleanup in this directory instead of the default one.")
193
- parser.add_argument('--cleanup-specific-archives', type=str, help="Comma separated list of archives to cleanup")
192
+ parser.add_argument('--cleanup-specific-archives', type=str, help="Comma separated list of archives to cleanup").completer = list_archive_completer
194
193
  parser.add_argument('-l', '--list', action='store_true', help="List available archives.")
195
194
  parser.add_argument('--verbose', action='store_true', help="Print various status messages to screen")
196
195
  parser.add_argument('--log-level', type=str, help="`debug` or `trace`, default is `info`", default="info")
197
196
  parser.add_argument('--log-stdout', action='store_true', help='also print log messages to stdout')
198
197
  parser.add_argument('--test-mode', action='store_true', help='Read envvars in order to run some pytest cases')
198
+
199
+ argcomplete.autocomplete(parser)
200
+
199
201
  args = parser.parse_args()
200
202
 
201
203
  args.config_file = os.path.expanduser(os.path.expandvars(args.config_file))
@@ -215,21 +217,24 @@ def main():
215
217
  command_logger = get_logger(command_output_logger = True)
216
218
  runner = CommandRunner(logger=logger, command_logger=command_logger)
217
219
 
218
- logger.info(f"=====================================")
219
- 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__))
220
223
 
221
224
  logger.info(f"START TIME: {start_time}")
222
225
  logger.debug(f"`args`:\n{args}")
223
226
  logger.debug(f"`config_settings`:\n{config_settings}")
224
227
 
225
228
  file_dir = os.path.normpath(os.path.dirname(__file__))
226
- args.verbose and (print(f"Script directory: {file_dir}"))
227
- args.verbose and (print(f"Config file: {args.config_file}"))
228
- args.verbose and (print(f"Backup dir: {config_settings.backup_dir}"))
229
- args.verbose and (print(f"Logfile location: {config_settings.logfile_location}"))
230
- args.verbose and (print(f"--alternate-archive-dir: {args.alternate_archive_dir}"))
231
- args.verbose and (print(f"--cleanup-specific-archives:{args.cleanup_specific_archives}"))
232
-
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)
233
238
 
234
239
  # run PREREQ scripts
235
240
  requirements('PREREQ', config_settings)
@@ -3,8 +3,11 @@ import logging
3
3
  import threading
4
4
  import os
5
5
  import sys
6
+ import tempfile
6
7
  sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../src")))
7
8
  from typing import List, Optional
9
+ from dar_backup.util import get_logger
10
+
8
11
 
9
12
  class CommandResult:
10
13
  def __init__(self, returncode: int, stdout: str, stderr: str):
@@ -15,6 +18,7 @@ class CommandResult:
15
18
  def __repr__(self):
16
19
  return f"<CommandResult returncode={self.returncode}>"
17
20
 
21
+
18
22
  class CommandRunner:
19
23
  def __init__(
20
24
  self,
@@ -22,10 +26,41 @@ class CommandRunner:
22
26
  command_logger: Optional[logging.Logger] = None,
23
27
  default_timeout: int = 30
24
28
  ):
25
- self.logger = logger or logging.getLogger(__name__)
26
- self.command_logger = command_logger or self.logger
29
+ self.logger = logger or get_logger()
30
+ self.command_logger = command_logger or get_logger(command_output_logger=True)
27
31
  self.default_timeout = default_timeout
28
32
 
33
+ if not self.logger or not self.command_logger:
34
+ self.logger_fallback()
35
+
36
+ def logger_fallback(self):
37
+ """
38
+ Setup temporary log files
39
+ """
40
+ main_log = tempfile.NamedTemporaryFile(delete=False)
41
+ command_log = tempfile.NamedTemporaryFile(delete=False)
42
+
43
+ logger = logging.getLogger("command_runner_fallback_main_logger")
44
+ command_logger = logging.getLogger("command_runner_fallback_command_logger")
45
+ logger.setLevel(logging.DEBUG)
46
+ command_logger.setLevel(logging.DEBUG)
47
+
48
+ main_handler = logging.FileHandler(main_log.name)
49
+ command_handler = logging.FileHandler(command_log.name)
50
+
51
+ logger.addHandler(main_handler)
52
+ command_logger.addHandler(command_handler)
53
+
54
+ self.logger = logger
55
+ self.command_logger = command_logger
56
+ self.default_timeout = 30
57
+ self.logger.info("CommandRunner initialized with fallback loggers")
58
+ self.command_logger.info("CommandRunner initialized with fallback loggers")
59
+
60
+ print(f"[WARN] Using fallback loggers:\n Main log: {main_log.name}\n Command log: {command_log.name}", file=sys.stderr)
61
+
62
+
63
+
29
64
  def run(
30
65
  self,
31
66
  cmd: List[str],
@@ -36,24 +71,39 @@ class CommandRunner:
36
71
  text: bool = True
37
72
  ) -> CommandResult:
38
73
  timeout = timeout or self.default_timeout
39
- self.logger.debug(f"Executing command: {' '.join(cmd)} (timeout={timeout}s)")
74
+
75
+ #log the command to be executed
76
+ command = f"Executing command: {' '.join(cmd)} (timeout={timeout}s)"
77
+ self.command_logger.info(command) # log to command logger
78
+ self.logger.debug(command) # log to main logger if "--log-level debug"
40
79
 
41
80
  process = subprocess.Popen(
42
81
  cmd,
43
82
  stdout=subprocess.PIPE if capture_output else None,
44
83
  stderr=subprocess.PIPE if capture_output else None,
45
- text=text,
46
- bufsize=1
84
+ text=False,
85
+ bufsize=-1
47
86
  )
48
87
 
49
88
  stdout_lines = []
50
89
  stderr_lines = []
51
90
 
91
+
52
92
  def stream_output(stream, lines, level):
53
- for line in iter(stream.readline, ''):
54
- lines.append(line)
55
- self.command_logger.log(level, line.strip())
56
- stream.close()
93
+ try:
94
+ while True:
95
+ chunk = stream.read(1024)
96
+ if not chunk:
97
+ break
98
+ decoded = chunk.decode('utf-8', errors='replace')
99
+ lines.append(decoded)
100
+ self.command_logger.log(level, decoded.strip())
101
+ except Exception as e:
102
+ self.logger.warning(f"stream_output decode error: {e}")
103
+ finally:
104
+ stream.close()
105
+
106
+
57
107
 
58
108
  threads = []
59
109
  if capture_output and process.stdout:
@@ -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