dar-backup 0.6.18__py3-none-any.whl → 0.6.19__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,21 @@
1
1
  <!-- markdownlint-disable MD024 -->
2
2
  # dar-backup Changelog
3
3
 
4
+ ## v2-beta-0.6.19 - 2025-04-21
5
+
6
+ Github link: [v2-beta-0.6.19](https://github.com/per2jensen/dar-backup/tree/v2-beta-0.6.19/v2)
7
+
8
+ ### Added
9
+
10
+ - removed a BackupError in the verify() to reduce noise in logs and let the rest of "compares" run.
11
+ - Added bash and zsh auto completion for a nicer CLI experience.
12
+
13
+ -- See [README for details](https://github.com/per2jensen/dar-backup?tab=readme-ov-file#shell-autocompletion)
14
+
15
+ - Improvement to command_runner.run(), more robust decoding
16
+
17
+ - 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.
18
+
4
19
  ## v2-beta-0.6.18 - 2025-04-05
5
20
 
6
21
  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.19"
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,6 +13,7 @@ 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
@@ -31,6 +32,7 @@ from dar_backup.util import list_backups
31
32
  from dar_backup.util import setup_logging
32
33
  from dar_backup.util import get_logger
33
34
  from dar_backup.util import requirements
35
+ from dar_backup.util import backup_definition_completer, list_archive_completer
34
36
 
35
37
  from dar_backup.command_runner import CommandRunner
36
38
  from dar_backup.command_runner import CommandResult
@@ -186,16 +188,19 @@ def main():
186
188
  global logger, runner
187
189
 
188
190
  parser = argparse.ArgumentParser(description="Cleanup old archives according to AGE configuration.")
189
- parser.add_argument('-d', '--backup-definition', help="Specific backup definition to cleanup.")
191
+ parser.add_argument('-d', '--backup-definition', help="Specific backup definition to cleanup.").completer = backup_definition_completer
190
192
  parser.add_argument('-c', '--config-file', '-c', type=str, help="Path to 'dar-backup.conf'", default='~/.config/dar-backup/dar-backup.conf')
191
193
  parser.add_argument('-v', '--version', action='store_true', help="Show version information.")
192
194
  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")
195
+ parser.add_argument('--cleanup-specific-archives', type=str, help="Comma separated list of archives to cleanup").completer = list_archive_completer
194
196
  parser.add_argument('-l', '--list', action='store_true', help="List available archives.")
195
197
  parser.add_argument('--verbose', action='store_true', help="Print various status messages to screen")
196
198
  parser.add_argument('--log-level', type=str, help="`debug` or `trace`, default is `info`", default="info")
197
199
  parser.add_argument('--log-stdout', action='store_true', help='also print log messages to stdout')
198
200
  parser.add_argument('--test-mode', action='store_true', help='Read envvars in order to run some pytest cases')
201
+
202
+ argcomplete.autocomplete(parser)
203
+
199
204
  args = parser.parse_args()
200
205
 
201
206
  args.config_file = os.path.expanduser(os.path.expandvars(args.config_file))
@@ -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:
dar_backup/dar_backup.py CHANGED
@@ -14,6 +14,8 @@ This script can be used to control `dar` to backup parts of or the whole system.
14
14
  """
15
15
 
16
16
 
17
+
18
+ import argcomplete
17
19
  import argparse
18
20
  import filecmp
19
21
 
@@ -46,12 +48,15 @@ from dar_backup.util import get_logger
46
48
  from dar_backup.util import BackupError
47
49
  from dar_backup.util import RestoreError
48
50
  from dar_backup.util import requirements
51
+ from dar_backup.util import get_binary_info
52
+ from dar_backup.util import backup_definition_completer, list_archive_completer
49
53
 
50
54
  from dar_backup.command_runner import CommandRunner
51
55
  from dar_backup.command_runner import CommandResult
52
56
 
53
57
  from dar_backup.rich_progress import show_log_driven_bar
54
58
 
59
+ from argcomplete.completers import FilesCompleter
55
60
 
56
61
  logger = None
57
62
  runner = None
@@ -312,7 +317,8 @@ def verify(args: argparse.Namespace, backup_file: str, backup_definition: str, c
312
317
  if filecmp.cmp(os.path.join(config_settings.test_restore_dir, restored_file_path.lstrip("/")), os.path.join(root_path, restored_file_path.lstrip("/")), shallow=False):
313
318
  args.verbose and logger.info(f"Success: file '{restored_file_path}' matches the original")
314
319
  else:
315
- raise BackupError(f"Failure: file '{restored_file_path}' did not match the original")
320
+ result = False
321
+ logger.error(f"Failure: file '{restored_file_path}' did not match the original")
316
322
  except PermissionError:
317
323
  result = False
318
324
  logger.exception(f"Permission error while comparing files, continuing....")
@@ -523,6 +529,10 @@ def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, ba
523
529
 
524
530
  # Perform backup
525
531
  backup_result = generic_backup(backup_type, command, backup_file, backup_definition_path, args.darrc, config_settings, args)
532
+ if not isinstance(backup_result, list) or not all(isinstance(i, tuple) and len(i) == 2 for i in backup_result):
533
+ logger.error("Unexpected return format from generic_backup")
534
+ backup_result = [("Unexpected return format from generic_backup", 1)]
535
+
526
536
  results.extend(backup_result)
527
537
 
528
538
  logger.info("Starting verification...")
@@ -752,15 +762,15 @@ def main():
752
762
  parser.add_argument('-F', '--full-backup', action='store_true', help="Perform a full backup.")
753
763
  parser.add_argument('-D', '--differential-backup', action='store_true', help="Perform differential backup.")
754
764
  parser.add_argument('-I', '--incremental-backup', action='store_true', help="Perform incremental backup.")
755
- parser.add_argument('-d', '--backup-definition', help="Specific 'recipe' to select directories and files.")
756
- parser.add_argument('--alternate-reference-archive', help="DIFF or INCR compared to specified archive.")
765
+ parser.add_argument('-d', '--backup-definition', help="Specific 'recipe' to select directories and files.").completer = backup_definition_completer
766
+ parser.add_argument('--alternate-reference-archive', help="DIFF or INCR compared to specified archive.").completer = list_archive_completer
757
767
  parser.add_argument('-c', '--config-file', type=str, help="Path to 'dar-backup.conf'", default='~/.config/dar-backup/dar-backup.conf')
758
768
  parser.add_argument('--darrc', type=str, help='Optional path to .darrc')
759
- parser.add_argument('-l', '--list', action='store_true', help="List available archives.")
760
- parser.add_argument('--list-contents', help="List the contents of the specified archive.")
769
+ parser.add_argument('-l', '--list', action='store_true', help="List available archives.").completer = list_archive_completer
770
+ parser.add_argument('--list-contents', help="List the contents of the specified archive.").completer = list_archive_completer
761
771
  parser.add_argument('--selection', help="dar file selection for listing/restoring specific files/directories.")
762
772
  # parser.add_argument('-r', '--restore', nargs=1, type=str, help="Restore specified archive.")
763
- parser.add_argument('-r', '--restore', type=str, help="Restore specified archive.")
773
+ parser.add_argument('-r', '--restore', type=str, help="Restore specified archive.").completer = list_archive_completer
764
774
  parser.add_argument('--restore-dir', type=str, help="Directory to restore files to.")
765
775
  parser.add_argument('--verbose', action='store_true', help="Print various status messages to screen")
766
776
  parser.add_argument('--suppress-dar-msg', action='store_true', help="cancel dar options in .darrc: -vt, -vs, -vd, -vf and -va")
@@ -773,6 +783,8 @@ def main():
773
783
  parser.add_argument("--changelog", action="store_true", help="Print Changelog.md to stdout and exit.")
774
784
  parser.add_argument("--changelog-pretty", action="store_true", help="Print Changelog.md to stdout with Markdown styling and exit.")
775
785
  parser.add_argument('-v', '--version', action='store_true', help="Show version and license information.")
786
+
787
+ argcomplete.autocomplete(parser)
776
788
  args = parser.parse_args()
777
789
 
778
790
  if args.version:
@@ -840,6 +852,9 @@ def main():
840
852
  logger.info(f"START TIME: {start_time}")
841
853
  logger.debug(f"`args`:\n{args}")
842
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']}")
843
858
 
844
859
  file_dir = os.path.normpath(os.path.dirname(__file__))
845
860
  args.verbose and (print(f"Script directory: {file_dir}"))
dar_backup/manager.py CHANGED
@@ -20,11 +20,14 @@
20
20
  This script creates and maintains `dar` databases with catalogs.
21
21
  """
22
22
 
23
-
23
+ import argcomplete
24
24
  import argparse
25
25
  import os
26
26
  import re
27
27
  import sys
28
+ import subprocess
29
+
30
+ from inputimeout import inputimeout, TimeoutOccurred
28
31
 
29
32
 
30
33
  from . import __about__ as about
@@ -32,9 +35,11 @@ from dar_backup.config_settings import ConfigSettings
32
35
  from dar_backup.util import setup_logging
33
36
  from dar_backup.util import CommandResult
34
37
  from dar_backup.util import get_logger
38
+ from dar_backup.util import get_binary_info
35
39
 
36
40
  from dar_backup.command_runner import CommandRunner
37
41
  from dar_backup.command_runner import CommandResult
42
+ from dar_backup.util import backup_definition_completer, list_archive_completer, archive_content_completer, add_specific_archive_completer
38
43
 
39
44
  from datetime import datetime
40
45
  from time import time
@@ -82,43 +87,58 @@ def create_db(backup_def: str, config_settings: ConfigSettings):
82
87
 
83
88
  return process.returncode
84
89
 
85
-
86
- def list_catalogs(backup_def: str, config_settings: ConfigSettings) -> NamedTuple:
90
+ def list_catalogs(backup_def: str, config_settings: ConfigSettings, suppress_output=False) -> CommandResult:
87
91
  """
92
+ List catalogs from the database for the given backup definition.
93
+
88
94
  Returns:
89
- a typing.NamedTuple of class dar-backup.util.CommandResult with the following properties:
90
- - process: of type subprocess.CompletedProcess: The result of the command execution.
91
- - stdout: of type str: The standard output of the command.
92
- - stderr: of type str: The standard error of the command.
93
- - returncode: of type int: The return code of the command.
94
- - timeout: of type int: The timeout value in seconds used to run the command.
95
- - command: of type list[str): The command executed.
95
+ A CommandResult containing the raw stdout/stderr and return code.
96
96
  """
97
97
  database = f"{backup_def}{DB_SUFFIX}"
98
98
  database_path = os.path.join(config_settings.backup_dir, database)
99
+
99
100
  if not os.path.exists(database_path):
100
101
  error_msg = f'Database not found: "{database_path}"'
101
102
  logger.error(error_msg)
102
103
  return CommandResult(1, '', error_msg)
103
104
 
104
- # commandResult = CommandResult(
105
- # process=None,
106
- # stdout='',
107
- # stderr=error_msg,
108
- # returncode=1,
109
- # timeout=1,
110
- # command=[])
111
-
112
- # return commandResult
113
105
  command = ['dar_manager', '--base', database_path, '--list']
114
106
  process = runner.run(command)
115
- stdout, stderr = process.stdout, process.stderr
107
+ stdout, stderr = process.stdout, process.stderr
108
+
116
109
  if process.returncode != 0:
117
110
  logger.error(f'Error listing catalogs for: "{database_path}"')
118
- logger.error(f"stderr: {stderr}")
111
+ logger.error(f"stderr: {stderr}")
119
112
  logger.error(f"stdout: {stdout}")
120
- else:
121
- print(stdout)
113
+ return process
114
+
115
+ # Extract only archive basenames from stdout
116
+ archive_names = []
117
+ for line in stdout.splitlines():
118
+ line = line.strip()
119
+ if not line or "archive #" in line or "dar path" in line or "compression" in line:
120
+ continue
121
+ parts = line.split("\t")
122
+ if len(parts) >= 3:
123
+ archive_names.append(parts[2].strip())
124
+
125
+ # Sort by prefix and date
126
+ def extract_date(arch_name):
127
+ match = re.search(r"(\d{4}-\d{2}-\d{2})", arch_name)
128
+ if match:
129
+ return datetime.strptime(match.group(1), "%Y-%m-%d")
130
+ return datetime.min
131
+
132
+ def sort_key(name):
133
+ prefix = name.split("_", 1)[0]
134
+ return (prefix, extract_date(name))
135
+
136
+ archive_names = sorted(archive_names, key=sort_key)
137
+
138
+ if not suppress_output:
139
+ for name in archive_names:
140
+ print(name)
141
+
122
142
  return process
123
143
 
124
144
 
@@ -130,47 +150,65 @@ def cat_no_for_name(archive: str, config_settings: ConfigSettings) -> int:
130
150
  - the found number, if the archive catalog is present in the database
131
151
  - "-1" if the archive is not found
132
152
  """
153
+
133
154
  backup_def = backup_def_from_archive(archive)
134
- process = list_catalogs(backup_def, config_settings)
155
+ process = list_catalogs(backup_def, config_settings, suppress_output=True)
135
156
  if process.returncode != 0:
136
157
  logger.error(f"Error listing catalogs for backup def: '{backup_def}'")
137
158
  return -1
138
159
  line_no = 1
139
160
  for line in process.stdout.splitlines():
140
- #print(f"{line_no}: '{line}'")
141
161
  line_no += 1
142
162
  search = re.search(rf".*?(\d+)\s+.*?({archive}).*", line)
143
163
  if search:
144
- #print(f"FOUND: archive: {search.group(2)}, catalog #: '{search.group(1)}'")
145
164
  logger.info(f"Found archive: '{archive}', catalog #: '{search.group(1)}'")
146
165
  return int(search.group(1))
147
166
  return -1
148
167
 
149
168
 
150
-
151
- def list_archive_contents(archive: str, config_settings: ConfigSettings) -> int :
169
+ def list_archive_contents(archive: str, config_settings: ConfigSettings) -> int:
152
170
  """
153
- List the contents of a specific archive, given the archive name
171
+ List the contents of a specific archive, given the archive name.
172
+ Prints only actual file entries (lines beginning with '[ Saved ]').
173
+ If none are found, a notice is printed instead.
154
174
  """
155
175
  backup_def = backup_def_from_archive(archive)
156
176
  database = f"{backup_def}{DB_SUFFIX}"
157
177
  database_path = os.path.join(config_settings.backup_dir, database)
178
+
158
179
  if not os.path.exists(database_path):
159
180
  logger.error(f'Database not found: "{database_path}"')
160
181
  return 1
182
+
161
183
  cat_no = cat_no_for_name(archive, config_settings)
162
184
  if cat_no < 0:
163
185
  logger.error(f"archive: '{archive}' not found in database: '{database_path}'")
164
186
  return 1
187
+
188
+
165
189
  command = ['dar_manager', '--base', database_path, '-u', f"{cat_no}"]
166
- process = runner.run(command)
167
- stdout, stderr = process.stdout, process.stderr
190
+ process = runner.run(command, timeout = 10)
191
+
192
+
193
+ stdout = process.stdout or ""
194
+ stderr = process.stderr or ""
195
+
196
+
168
197
  if process.returncode != 0:
169
198
  logger.error(f'Error listing catalogs for: "{database_path}"')
170
- logger.error(f"stderr: {stderr}")
199
+ logger.error(f"stderr: {stderr}")
171
200
  logger.error(f"stdout: {stdout}")
201
+
202
+
203
+ combined_lines = (stdout + "\n" + stderr).splitlines()
204
+ file_lines = [line for line in combined_lines if line.strip().startswith("[ Saved ]")]
205
+
206
+ if file_lines:
207
+ for line in file_lines:
208
+ print(line)
172
209
  else:
173
- print(stdout)
210
+ print(f"[info] Archive '{archive}' is empty.")
211
+
174
212
  return process.returncode
175
213
 
176
214
 
@@ -217,42 +255,82 @@ def find_file(file, backup_def, config_settings):
217
255
  return process.returncode
218
256
 
219
257
 
220
- def add_specific_archive(archive: str, config_settings: ConfigSettings, directory: str =None) -> int:
221
- # sanity check - does dar backup exist?
258
+ def add_specific_archive(archive: str, config_settings: ConfigSettings, directory: str = None) -> int:
259
+ """
260
+ Adds the specified archive to its catalog database. Prompts for confirmation if it's older than existing entries.
261
+
262
+ Returns:
263
+ 0 on success
264
+ 1 on failure
265
+ """
266
+ # Determine archive path
222
267
  if not directory:
223
268
  directory = config_settings.backup_dir
224
- archive = os.path.basename(archive) # remove path if it was given
225
- archive_path = os.path.join(directory, f'{archive}')
269
+ archive = os.path.basename(archive) # strip path if present
270
+ archive_path = os.path.join(directory, archive)
271
+ archive_test_path = os.path.join(directory, f'{archive}.1.dar')
226
272
 
227
- archive_test_path = os.path.join(directory, f'{archive}.1.dar')
228
273
  if not os.path.exists(archive_test_path):
229
274
  logger.error(f'dar backup: "{archive_test_path}" not found, exiting')
230
275
  return 1
231
-
232
- # sanity check - does backup definition exist?
276
+
277
+ # Validate backup definition
233
278
  backup_definition = archive.split('_')[0]
234
279
  backup_def_path = os.path.join(config_settings.backup_d_dir, backup_definition)
235
280
  if not os.path.exists(backup_def_path):
236
281
  logger.error(f'backup definition "{backup_definition}" not found (--add-specific-archive option probably not correct), exiting')
237
282
  return 1
238
-
283
+
284
+ # Determine catalog DB path
239
285
  database = f"{backup_definition}{DB_SUFFIX}"
240
286
  database_path = os.path.realpath(os.path.join(config_settings.backup_dir, database))
287
+
288
+ # Safety check: is archive older than latest in catalog?
289
+ try:
290
+ result = subprocess.run(
291
+ ["dar_manager", "--base", database_path, "--list"],
292
+ stdout=subprocess.PIPE,
293
+ stderr=subprocess.DEVNULL,
294
+ text=True,
295
+ check=True
296
+ )
297
+ all_lines = result.stdout.splitlines()
298
+ date_pattern = re.compile(r"\d{4}-\d{2}-\d{2}")
299
+
300
+ catalog_dates = [
301
+ datetime.strptime(date_match.group(), "%Y-%m-%d")
302
+ for line in all_lines
303
+ if (date_match := date_pattern.search(line))
304
+ ]
305
+
306
+ if catalog_dates:
307
+ latest_date = max(catalog_dates)
308
+ archive_date_match = date_pattern.search(archive)
309
+ if archive_date_match:
310
+ archive_date = datetime.strptime(archive_date_match.group(), "%Y-%m-%d")
311
+ if archive_date < latest_date:
312
+ if not confirm_add_old_archive(archive, latest_date.strftime("%Y-%m-%d")):
313
+ logger.info(f"Archive {archive} skipped due to user declining to add older archive.")
314
+ return 1
315
+
316
+ except subprocess.CalledProcessError:
317
+ logger.warning("Could not determine latest catalog date for chronological check.")
318
+
241
319
  logger.info(f'Add "{archive_path}" to catalog: "{database}"')
242
-
243
- command = ['dar_manager', '--base', database_path, "--add", archive_path, "-Q"]
320
+
321
+ command = ['dar_manager', '--base', database_path, "--add", archive_path, "-Q", "--alter=ignore-order"]
244
322
  process = runner.run(command)
245
323
  stdout, stderr = process.stdout, process.stderr
246
324
 
247
325
  if process.returncode == 0:
248
- logger.info(f'"{archive_path}" added to it\'s catalog')
326
+ logger.info(f'"{archive_path}" added to its catalog')
249
327
  elif process.returncode == 5:
250
- logger.warning(f'Something did not go completely right adding "{archive_path}" to it\'s catalog, dar_manager error: "{process.returncode}"')
251
- else:
252
- logger.error(f'something went wrong adding "{archive_path}" to it\'s catalog, dar_manager error: "{process.returncode}"')
328
+ logger.warning(f'Something did not go completely right adding "{archive_path}" to its catalog, dar_manager error: "{process.returncode}"')
329
+ else:
330
+ logger.error(f'something went wrong adding "{archive_path}" to its catalog, dar_manager error: "{process.returncode}"')
253
331
  logger.error(f"stderr: {stderr}")
254
332
  logger.error(f"stdout: {stdout}")
255
-
333
+
256
334
  return process.returncode
257
335
 
258
336
 
@@ -330,6 +408,31 @@ def backup_def_from_archive(archive: str) -> str:
330
408
  return None
331
409
 
332
410
 
411
+ def confirm_add_old_archive(archive_name: str, latest_known_date: str, timeout_secs: int = 20) -> bool:
412
+ """
413
+ Confirm with the user if they want to proceed with adding an archive older than the most recent in the catalog.
414
+ Returns True if the user confirms with "yes", False otherwise.
415
+ """
416
+ try:
417
+ prompt = (
418
+ f"⚠️ Archive '{archive_name}' is older than the latest in the catalog ({latest_known_date}).\n"
419
+ f"Adding older archives may lead to inconsistent restore chains.\n"
420
+ f"Are you sure you want to continue? (yes/no): "
421
+ )
422
+ confirmation = inputimeout(prompt=prompt, timeout=timeout_secs)
423
+
424
+ if confirmation is None:
425
+ logger.info(f"No confirmation received for old archive: {archive_name}. Skipping.")
426
+ return False
427
+ return confirmation.strip().lower() == "yes"
428
+
429
+ except TimeoutOccurred:
430
+ logger.info(f"Timeout waiting for confirmation for old archive: {archive_name}. Skipping.")
431
+ return False
432
+ except KeyboardInterrupt:
433
+ logger.info(f"User interrupted confirmation for old archive: {archive_name}. Skipping.")
434
+ return False
435
+
333
436
 
334
437
  def remove_specific_archive(archive: str, config_settings: ConfigSettings) -> int:
335
438
  """
@@ -366,12 +469,11 @@ def build_arg_parser():
366
469
  parser.add_argument('--create-db', action='store_true', help='Create missing databases for all backup definitions')
367
470
  parser.add_argument('--alternate-archive-dir', type=str, help='Use this directory instead of BACKUP_DIR in config file')
368
471
  parser.add_argument('--add-dir', type=str, help='Add all archive catalogs in this directory to databases')
369
- parser.add_argument('-d', '--backup-def', type=str, help='Restrict to work only on this backup definition')
370
- parser.add_argument('--add-specific-archive', type=str, help='Add this archive to catalog database')
371
- parser.add_argument('--remove-specific-archive', type=str, help='Remove this archive from catalog database')
472
+ parser.add_argument('-d', '--backup-def', type=str, help='Restrict to work only on this backup definition').completer = backup_definition_completer
473
+ parser.add_argument('--add-specific-archive', type=str, help='Add this archive to catalog database').completer = add_specific_archive_completer
474
+ parser.add_argument('--remove-specific-archive', type=str, help='Remove this archive from catalog database').completer = archive_content_completer
372
475
  parser.add_argument('-l', '--list-catalogs', action='store_true', help='List catalogs in databases for all backup definitions')
373
- parser.add_argument('--list-catalog-contents', type=int, help="List contents of a catalog. Argument is the 'archive #', '-d <definition>' argument is also required")
374
- parser.add_argument('--list-archive-contents', type=str, help="List contents of the archive's catalog.")
476
+ parser.add_argument('--list-archive-contents', type=str, help="List contents of the archive's catalog. Argument is the archive name.").completer = archive_content_completer
375
477
  parser.add_argument('--find-file', type=str, help="List catalogs containing <path>/file. '-d <definition>' argument is also required")
376
478
  parser.add_argument('--verbose', action='store_true', help='Be more verbose')
377
479
  parser.add_argument('--log-level', type=str, help="`debug` or `trace`, default is `info`", default="info")
@@ -394,9 +496,10 @@ def main():
394
496
  return
395
497
 
396
498
  parser = argparse.ArgumentParser(description="Creates/maintains `dar` database catalogs")
397
- # [parser.add_argument(...) as before...]
398
-
399
499
  parser = build_arg_parser()
500
+
501
+ argcomplete.autocomplete(parser)
502
+
400
503
  args = parser.parse_args()
401
504
 
402
505
  if args.more_help:
@@ -426,11 +529,14 @@ See section 15 and section 16 in the supplied "LICENSE" file.''')
426
529
  runner = CommandRunner(logger=logger, command_logger=command_logger)
427
530
 
428
531
  start_time = int(time())
429
- logger.info(f"=====================================")
430
532
  logger.info(f"{SCRIPTNAME} started, version: {about.__version__}")
431
533
  logger.info(f"START TIME: {start_time}")
432
534
  logger.debug(f"`args`:\n{args}")
433
535
  logger.debug(f"`config_settings`:\n{config_settings}")
536
+ 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']}")
539
+
434
540
 
435
541
  # --- Sanity checks ---
436
542
  if args.add_dir and not args.add_dir.strip():
@@ -475,11 +581,6 @@ See section 15 and section 16 in the supplied "LICENSE" file.''')
475
581
  sys.exit(1)
476
582
  return
477
583
 
478
- if args.list_catalog_contents and not args.backup_def:
479
- logger.error(f"--list-catalog-contents requires the --backup-def, exiting")
480
- sys.exit(1)
481
- return
482
-
483
584
  if args.find_file and not args.backup_def:
484
585
  logger.error(f"--find-file requires the --backup-def, exiting")
485
586
  sys.exit(1)
@@ -538,10 +639,6 @@ See section 15 and section 16 in the supplied "LICENSE" file.''')
538
639
  sys.exit(result)
539
640
  return
540
641
 
541
- if args.list_catalog_contents:
542
- result = list_catalog_contents(args.list_catalog_contents, args.backup_def, config_settings)
543
- sys.exit(result)
544
- return
545
642
 
546
643
  if args.find_file:
547
644
  result = find_file(args.find_file, args.backup_def, config_settings)
dar_backup/util.py CHANGED
@@ -9,6 +9,7 @@ See section 15 and section 16 in the supplied "LICENSE" file
9
9
  """
10
10
  import typing
11
11
  import locale
12
+ import configparser
12
13
  import logging
13
14
  import os
14
15
  import re
@@ -18,11 +19,15 @@ import shutil
18
19
  import sys
19
20
  import threading
20
21
  import traceback
22
+ from argcomplete.completers import ChoicesCompleter
21
23
  from datetime import datetime
22
24
  from dar_backup.config_settings import ConfigSettings
25
+ import dar_backup.__about__ as about
23
26
 
24
27
  from typing import NamedTuple, List
25
28
 
29
+
30
+
26
31
  logger=None
27
32
  secondary_logger=None
28
33
 
@@ -72,11 +77,9 @@ def setup_logging(log_file: str, command_output_log_file: str, log_level: str =
72
77
  stdout_handler = logging.StreamHandler(sys.stdout)
73
78
  stdout_handler.setFormatter(formatter)
74
79
  logger.addHandler(stdout_handler)
75
- #secondary_logger.addHandler(stdout_handler)
76
80
 
77
81
  return logger
78
82
  except Exception as e:
79
- print("Logging initialization failed.")
80
83
  traceback.print_exc()
81
84
  sys.exit(1)
82
85
 
@@ -98,6 +101,66 @@ def get_logger(command_output_logger: bool = False) -> logging.Logger:
98
101
 
99
102
 
100
103
 
104
+ def extract_version(output):
105
+ match = re.search(r'(\d+\.\d+(\.\d+)?)', output)
106
+ return match.group(1) if match else "unknown"
107
+
108
+ def get_binary_info(command):
109
+ """
110
+ Return information about a binary command.
111
+ Args:
112
+ command (str): The command to check.
113
+ Returns:
114
+ dict: A dictionary containing the command, path, version, and full output.
115
+ Dict structure:
116
+ {
117
+ "command": str,
118
+ "path": str,
119
+ "version": str,
120
+ "full_output": str
121
+ }
122
+ Raises:
123
+ Exception: If there is an error running the command.
124
+ """
125
+ path = shutil.which(command)
126
+ if path is None:
127
+ return {
128
+ "command": command,
129
+ "path": "Not found",
130
+ "version": "unknown",
131
+ "full_output": ""
132
+ }
133
+
134
+ try:
135
+ result = subprocess.run(
136
+ [path, '--version'],
137
+ stdout=subprocess.PIPE,
138
+ stderr=subprocess.PIPE,
139
+ text=True
140
+ )
141
+
142
+ # Combine output regardless of return code
143
+ combined_output = (result.stdout + result.stderr).strip()
144
+
145
+ # Even if returncode != 0, the version info may still be valid
146
+ version = extract_version(combined_output)
147
+
148
+ return {
149
+ "command": command,
150
+ "path": path,
151
+ "version": version if version else "unknown",
152
+ "full_output": combined_output
153
+ }
154
+
155
+ except Exception as e:
156
+ return {
157
+ "command": command,
158
+ "path": path,
159
+ "version": "error",
160
+ "full_output": str(e)
161
+ }
162
+
163
+
101
164
  def requirements(type: str, config_setting: ConfigSettings):
102
165
  """
103
166
  Perform PREREQ or POSTREQ requirements.
@@ -238,4 +301,186 @@ def list_backups(backup_dir, backup_definition=None):
238
301
  print(f"{backup.ljust(max_name_length)} : {formatted_size.rjust(max_size_length)} MB")
239
302
 
240
303
 
304
+ def expand_path(path: str) -> str:
305
+ """
306
+ Expand ~ and environment variables like $HOME in a path.
307
+ """
308
+ return os.path.expanduser(os.path.expandvars(path))
309
+
310
+
311
+
312
+ def backup_definition_completer(prefix, parsed_args, **kwargs):
313
+ config_path = getattr(parsed_args, 'config_file', '~/.config/dar-backup/dar-backup.conf')
314
+ config_path = expand_path(config_path)
315
+ config_file = os.path.expanduser(config_path)
316
+ try:
317
+ config = ConfigSettings(config_file)
318
+ backup_d_dir = os.path.expanduser(config.backup_d_dir)
319
+ return [f for f in os.listdir(backup_d_dir) if f.startswith(prefix)]
320
+ except Exception:
321
+ return []
322
+
323
+
324
+ def extract_backup_definition_fallback() -> str:
325
+ """
326
+ Extracts --backup-definition or -d value directly from COMP_LINE.
327
+ This is needed because argcomplete doesn't always populate parsed_args fully.
328
+
329
+ Returns:
330
+ str: The value of the --backup-definition argument if found, else an empty string.
331
+ """
332
+ comp_line = os.environ.get("COMP_LINE", "")
333
+ # Match both "--backup-definition VALUE" and "-d VALUE"
334
+ match = re.search(r"(--backup-definition|-d)\s+([^\s]+)", comp_line)
335
+ if match:
336
+ return match.group(2)
337
+ return ""
338
+
339
+
340
+
341
+ def list_archive_completer(prefix, parsed_args, **kwargs):
342
+ import os
343
+ import configparser
344
+ from dar_backup.util import extract_backup_definition_fallback
345
+
346
+ backup_def = getattr(parsed_args, "backup_definition", None) or extract_backup_definition_fallback()
347
+ config_path = getattr(parsed_args, "config_file", None) or "~/.config/dar-backup/dar-backup.conf"
348
+
349
+ config_path = os.path.expanduser(os.path.expandvars(config_path))
350
+ if not os.path.exists(config_path):
351
+ return []
352
+
353
+ config = configparser.ConfigParser()
354
+ config.read(config_path)
355
+ backup_dir = config.get("DIRECTORIES", "BACKUP_DIR", fallback="")
356
+ backup_dir = os.path.expanduser(os.path.expandvars(backup_dir))
357
+
358
+ if not os.path.isdir(backup_dir):
359
+ return []
360
+
361
+ files = os.listdir(backup_dir)
362
+ 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
+
364
+ return [
365
+ f.rsplit(".1.dar", 1)[0]
366
+ for f in files
367
+ if archive_re.match(f)
368
+ ]
369
+
370
+
371
+ def archive_content_completer(prefix, parsed_args, **kwargs):
372
+ """
373
+ Completes archive names from all available *.db files.
374
+ If --backup-def is given, only that one is used.
375
+ Only entries found in the catalog database (via `dar_manager --list`) are shown.
376
+ """
377
+
378
+ from dar_backup.config_settings import ConfigSettings
379
+ import subprocess
380
+ import re
381
+ import os
382
+ from datetime import datetime
383
+
384
+ # Expand config path
385
+ config_file = expand_path(getattr(parsed_args, "config_file", "~/.config/dar-backup/dar-backup.conf"))
386
+ config = ConfigSettings(config_file=config_file)
387
+ backup_dir = config.backup_dir
388
+
389
+ # Which db files to inspect?
390
+ backup_def = getattr(parsed_args, "backup_def", None)
391
+ db_files = (
392
+ [os.path.join(backup_dir, f"{backup_def}.db")]
393
+ if backup_def
394
+ else [os.path.join(backup_dir, f) for f in os.listdir(backup_dir) if f.endswith(".db")]
395
+ )
396
+
397
+ completions = []
398
+
399
+ for db_path in db_files:
400
+ if not os.path.exists(db_path):
401
+ continue
402
+
403
+ try:
404
+ result = subprocess.run(
405
+ ["dar_manager", "--base", db_path, "--list"],
406
+ stdout=subprocess.PIPE,
407
+ stderr=subprocess.DEVNULL,
408
+ text=True,
409
+ check=True
410
+ )
411
+ except subprocess.CalledProcessError:
412
+ continue
413
+
414
+ for line in result.stdout.splitlines():
415
+ parts = line.strip().split("\t")
416
+ if len(parts) >= 3:
417
+ archive = parts[2].strip()
418
+ if archive.startswith(prefix):
419
+ completions.append(archive)
420
+
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
+ completions = sorted(set(completions), key=sort_key)
429
+ return completions or ["[no matching archives]"]
430
+
431
+
432
+
433
+ def add_specific_archive_completer(prefix, parsed_args, **kwargs):
434
+ """
435
+ Autocompletes archives that are present in the BACKUP_DIR
436
+ but not yet present in the <backup_def>.db catalog.
437
+ If --backup-def is provided, restrict suggestions to that.
438
+ """
439
+ from dar_backup.config_settings import ConfigSettings
440
+ import subprocess
441
+ import re
442
+ import os
443
+ from datetime import datetime
444
+
445
+ config_file = expand_path(getattr(parsed_args, "config_file", "~/.config/dar-backup/dar-backup.conf"))
446
+ config = ConfigSettings(config_file=config_file)
447
+ backup_dir = config.backup_dir
448
+ backup_def = getattr(parsed_args, "backup_def", None)
449
+
450
+ # Match pattern for archive base names: e.g. test_FULL_2025-04-01
451
+ dar_pattern = re.compile(r"^(.*?_(FULL|DIFF|INCR)_(\d{4}-\d{2}-\d{2}))\.1\.dar$")
452
+
453
+ # Step 1: scan backup_dir for .1.dar files
454
+ all_archives = set()
455
+ for fname in os.listdir(backup_dir):
456
+ match = dar_pattern.match(fname)
457
+ if match:
458
+ base = match.group(1)
459
+ if base.startswith(prefix):
460
+ if not backup_def or base.startswith(f"{backup_def}_"):
461
+ all_archives.add(base)
462
+
463
+ # 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
465
+ existing = set()
466
+
467
+ if db_path and os.path.exists(db_path):
468
+ try:
469
+ result = subprocess.run(
470
+ ["dar_manager", "--base", db_path, "--list"],
471
+ stdout=subprocess.PIPE,
472
+ stderr=subprocess.DEVNULL,
473
+ text=True,
474
+ check=True
475
+ )
476
+ for line in result.stdout.splitlines():
477
+ parts = line.strip().split("\t")
478
+ if len(parts) >= 3:
479
+ existing.add(parts[2].strip())
480
+ except subprocess.CalledProcessError:
481
+ pass
482
+
483
+ # Step 3: return filtered list
484
+ candidates = sorted(archive for archive in all_archives if archive not in existing)
485
+ return candidates or ["[no new archives]"]
241
486
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dar-backup
3
- Version: 0.6.18
3
+ Version: 0.6.19
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
@@ -689,6 +689,7 @@ Classifier: Operating System :: POSIX :: Linux
689
689
  Classifier: Programming Language :: Python :: 3.9
690
690
  Classifier: Topic :: System :: Archiving :: Backup
691
691
  Requires-Python: >=3.9
692
+ Requires-Dist: argcomplete>=3.6.2
692
693
  Requires-Dist: inputimeout>=1.0.4
693
694
  Requires-Dist: rich>=13.0.0
694
695
  Description-Content-Type: text/markdown
@@ -697,12 +698,18 @@ Description-Content-Type: text/markdown
697
698
  # Full, differential or incremental backups using 'dar'
698
699
 
699
700
  [![codecov](https://codecov.io/gh/per2jensen/dar-backup/branch/main/graph/badge.svg)](https://codecov.io/gh/per2jensen/dar-backup)
701
+ [![PyPI monthly downloads](https://img.shields.io/pypi/dm/dar-backup)](https://pypi.org/project/dar-backup/)
702
+ [![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/)
700
703
 
701
704
  The wonderful 'dar' [Disk Archiver](https://github.com/Edrusb/DAR) is used for
702
705
  the heavy lifting, together with the par2 suite in these scripts.
703
706
 
704
707
  This is the `Python` based **version 2** of `dar-backup`.
705
708
 
709
+ ## TL;DR
710
+
711
+ `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.
712
+
706
713
  ## Table of Contents
707
714
 
708
715
  - [Full, differential or incremental backups using 'dar'](#full-differential-or-incremental-backups-using-dar)
@@ -728,6 +735,7 @@ This is the `Python` based **version 2** of `dar-backup`.
728
735
  - [Generate systemd files](#generate-systemd-files)
729
736
  - [Service: dar-back --incremental-backup](#service-dar-backup---incremental-backup)
730
737
  - [Timer: dar-back --incremental-backup](#timer-dar-backup---incremental-backup)
738
+ - [Systemd timer note](#systemd-timer-note)
731
739
  - [List contents of an archive](#list-contents-of-an-archive)
732
740
  - [dar file selection examples](#dar-file-selection-examples)
733
741
  - [Select a directory](#select-a-directory)
@@ -753,7 +761,9 @@ This is the `Python` based **version 2** of `dar-backup`.
753
761
  - [Skipping cache directories](#skipping-cache-directories)
754
762
  - [Progress bar + current directory](#progress-bar-and-current-directory)
755
763
  - [Todo](#todo)
764
+ - [Known Limitations / Edge Cases](#known-limitations--edge-cases)
756
765
  - [Reference](#reference)
766
+ - [CLI Tools Overview](#cli-tools-overview)
757
767
  - [Test coverage report](#test-coverage)
758
768
  - [dar-backup](#dar-backup-options)
759
769
  - [manager](#manager-options)
@@ -1346,6 +1356,10 @@ Persistent=true
1346
1356
  WantedBy=timers.target
1347
1357
  ````
1348
1358
 
1359
+ ## systemd timer note
1360
+
1361
+ 📅 OnCalendar syntax is flexible — you can tweak backup schedules easily. Run systemd-analyze calendar to preview timers.
1362
+
1349
1363
  ## list contents of an archive
1350
1364
 
1351
1365
  ```` bash
@@ -1636,8 +1650,27 @@ The indicators are not shown if dar-backup is run from systemd or if it is used
1636
1650
  - Look into a way to move the .par2 files away from the `dar` slices, to maximize chance of good redundancy.
1637
1651
  - Add option to dar-backup to use the `dar` option `--fsa-scope none`
1638
1652
 
1653
+ ## Known Limitations / Edge Cases
1654
+
1655
+ Does not currently encrypt data (by design — relies on encrypted storage)
1656
+
1657
+ One backup definition per file
1658
+
1659
+ .par2 files created for each slice (may be moved in future)
1660
+
1639
1661
  ## Reference
1640
1662
 
1663
+ ### CLI Tools Overview
1664
+
1665
+ | Command | Description |
1666
+ |-----------------------|-------------------------------------------|
1667
+ | `dar-backup` | Perform full, differential, or incremental backups with verification and restore testing |
1668
+ | `manager` | Maintain and query catalog databases for archives |
1669
+ | `cleanup` | Remove outdated DIFF/INCR archives (and optionally FULLs) |
1670
+ | `clean-log` | Clean up excessive log output from dar command logs |
1671
+ | `installer` | Set up required directories and default config files |
1672
+ | `dar-backup-systemd` | Generate (and optionally install) systemd timers and services for automated backups |
1673
+
1641
1674
  ### test coverage
1642
1675
 
1643
1676
  Running
@@ -1646,23 +1679,26 @@ Running
1646
1679
  pytest --cov=dar_backup tests/
1647
1680
  ````
1648
1681
 
1649
- results for version 0.6.17 in this report:
1682
+ results for a dev version 0.6.19 in this report:
1650
1683
 
1651
1684
  ```` code
1652
1685
  ---------- coverage: platform linux, python 3.12.3-final-0 -----------
1653
- Name Stmts Miss Cover
1654
- -------------------------------------------------------------------------------------
1655
- venv/lib/python3.12/site-packages/dar_backup/__about__.py 1 0 100%
1656
- venv/lib/python3.12/site-packages/dar_backup/__init__.py 0 0 100%
1657
- venv/lib/python3.12/site-packages/dar_backup/clean_log.py 68 14 79%
1658
- venv/lib/python3.12/site-packages/dar_backup/cleanup.py 196 53 73%
1659
- venv/lib/python3.12/site-packages/dar_backup/config_settings.py 66 8 88%
1660
- venv/lib/python3.12/site-packages/dar_backup/dar_backup.py 464 99 79%
1661
- venv/lib/python3.12/site-packages/dar_backup/installer.py 46 46 0%
1662
- venv/lib/python3.12/site-packages/dar_backup/manager.py 316 72 77%
1663
- venv/lib/python3.12/site-packages/dar_backup/util.py 162 34 79%
1664
- -------------------------------------------------------------------------------------
1665
- TOTAL 1319 326 75%
1686
+ Name Stmts Miss Cover
1687
+ ----------------------------------------------------------
1688
+ src/dar_backup/__about__.py 1 0 100%
1689
+ src/dar_backup/__init__.py 0 0 100%
1690
+ src/dar_backup/clean_log.py 68 13 81%
1691
+ src/dar_backup/cleanup.py 193 17 91%
1692
+ src/dar_backup/command_runner.py 73 1 99%
1693
+ src/dar_backup/config_settings.py 66 8 88%
1694
+ src/dar_backup/dar_backup.py 535 56 90%
1695
+ src/dar_backup/dar_backup_systemd.py 56 7 88%
1696
+ src/dar_backup/installer.py 59 6 90%
1697
+ src/dar_backup/manager.py 351 56 84%
1698
+ src/dar_backup/rich_progress.py 70 7 90%
1699
+ src/dar_backup/util.py 130 15 88%
1700
+ ----------------------------------------------------------
1701
+ TOTAL 1602 186 88%
1666
1702
  ````
1667
1703
 
1668
1704
  ### dar-backup options
@@ -0,0 +1,21 @@
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,,
@@ -1,21 +0,0 @@
1
- dar_backup/.darrc,sha256=-aerqivZmOsW_XBCh9IfbYTUvw0GkzDSr3Vx4GcNB1g,2113
2
- dar_backup/Changelog.md,sha256=ZRK03WnSbNWkMQAVNvfflKs06TGmkB4wBOm0Lp4ft1c,7830
3
- dar_backup/README.md,sha256=b4eoXh8fo7Li1mJqMKbrW-SrI2gDYkczCtL8G9-KoFE,43261
4
- dar_backup/__about__.py,sha256=g-mDk6iCtdmWAx2-NGjdBzw-GF98McMHGx0wkhbKFM4,22
5
- dar_backup/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
- dar_backup/clean_log.py,sha256=cGhtKYnQJ2ceNQfw5XcCln_WNBasbmlfhO3kRydjDNk,5196
7
- dar_backup/cleanup.py,sha256=BAiztBL0Dpr3fs6WZU_9oR5jUWumHua4dS37JtSxi-Q,12499
8
- dar_backup/command_runner.py,sha256=74Fsylz1NN-dn8lbdRhkL6LA1r527QJeojBlniGrPuo,2708
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=THZmKZgOWxtYo9HaysU38GZ1hnpTcziJ45sPQ4HvJoE,41003
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=HRuWeDB1sd14HwMEM6dk6OVqpj2KDTk6y55OKw7vUHE,22420
15
- dar_backup/rich_progress.py,sha256=jTwM-4VlqHHzKqIfyXjL1pWEriobSJwCdN3YXzXzRdo,3105
16
- dar_backup/util.py,sha256=2V4ONpoGh_W6wFv2RbJoUIEDxcY6_ot4frtzEBWsu7U,8798
17
- dar_backup-0.6.18.dist-info/METADATA,sha256=My1Utkqgj1iYhfC4OfWXzzzrU2bPasbWl9usZqS_7bw,84949
18
- dar_backup-0.6.18.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
19
- dar_backup-0.6.18.dist-info/entry_points.txt,sha256=NSCYoG5Dvh1UhvKWOQPgcHdFv4--R4Sre3d9FwJra3E,258
20
- dar_backup-0.6.18.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
21
- dar_backup-0.6.18.dist-info/RECORD,,