dar-backup 0.6.21__py3-none-any.whl → 0.7.2__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.
@@ -0,0 +1,62 @@
1
+
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+
4
+ # ------------------------------------------------------------------------
5
+ # Demo of a `dar-backup` definition file
6
+ # This file was generated by dar-backup's `demo` program.
7
+ #
8
+ {%- if opts_dict | length > 0 %}
9
+ # Options given to the `demo` program:
10
+ {% endif %}
11
+ {%- if opts_dict.ROOT_DIR -%}
12
+ # --root-dir : {{ opts_dict.ROOT_DIR }}
13
+ {% endif %}
14
+ {%- if opts_dict.DIR_TO_BACKUP -%}
15
+ # --dir-to-backup : {{ opts_dict.DIR_TO_BACKUP }}
16
+ {% endif -%}
17
+ {%- if opts_dict.BACKUP_DIR -%}
18
+ # --backup-dir : {{ opts_dict.BACKUP_DIR }}
19
+ {% endif %}
20
+ #
21
+ # Variables used to generate this file:
22
+ # =====================================
23
+ {% for k,v in vars_map|dictsort %}# {{ k }} : {{ v }}
24
+ {% endfor -%}
25
+ # ------------------------------------------------------------------------
26
+
27
+ # Switch to ordered selection mode, which means that the following options
28
+ # will be considered top to bottom
29
+ -am
30
+
31
+ # Backup Root dir
32
+ {%- if vars_map.ROOT_DIR %}
33
+ -R {{ vars_map.ROOT_DIR }}
34
+ {% endif -%}
35
+
36
+ {% if vars_map.DIR_TO_BACKUP %}
37
+ # Directories to backup below the Root dir
38
+ -g {{ vars_map.DIR_TO_BACKUP }}
39
+
40
+ # This is an example of exclusion of a `.private` directory inside the
41
+ # directory that is backed up
42
+ -P {{ vars_map.DIR_TO_BACKUP }}/.private
43
+ {%- else %}
44
+ # Examples of directories to exclude below the Root dir
45
+ -P mnt
46
+ -P .cache
47
+ {% endif %}
48
+
49
+ # compression level
50
+ -z5
51
+
52
+ # no overwrite, if you rerun a backup, 'dar' halts and asks what to do
53
+ # as `dar-backup` gives the `-Q` option to `dar`, the net effect of `-n` and `-Q` is
54
+ # that `dar` will quit and not overwrite the existing backup
55
+ -n
56
+
57
+ # size of each slice in the archive (10G is 10 Gigabytes)
58
+ --slice 10G
59
+
60
+ # bypass directores marked as cache directories
61
+ # http://dar.linux.free.fr/doc/Features.html
62
+ --cache-directory-tagging
dar_backup/exceptions.py CHANGED
@@ -1,3 +1,5 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+
1
3
  class ConfigSettingsError(Exception):
2
4
  """Raised when ConfigSettings encounters a critical error."""
3
5
  pass
dar_backup/installer.py CHANGED
@@ -1,3 +1,6 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+
1
4
  import argparse
2
5
  import os
3
6
  from . import __about__ as about
@@ -9,6 +12,91 @@ from dar_backup.manager import create_db
9
12
  # Always expand manager DB dir correctly, using helper function
10
13
  from dar_backup.manager import get_db_dir
11
14
  from dar_backup.util import expand_path
15
+ from dar_backup.util import is_safe_path
16
+
17
+ def install_autocompletion():
18
+ """Detect user shell, choose RC file, and idempotently append autocompletion."""
19
+ shell = Path(os.environ.get("SHELL", "")).name
20
+ home = Path.home()
21
+
22
+ # pick RC file based on shell
23
+ if shell == "zsh":
24
+ rc_file = home / ".zshrc"
25
+ elif shell == "bash":
26
+ # prefer ~/.bash_profile on macOS if present
27
+ rc_file = home / ".bash_profile" if (home / ".bash_profile").exists() else home / ".bashrc"
28
+ else:
29
+ rc_file = home / ".bashrc"
30
+
31
+ marker = "# >>> dar-backup autocompletion >>>"
32
+ end_marker = "# <<< dar-backup autocompletion <<<"
33
+
34
+ block = "\n".join([
35
+ marker,
36
+ 'eval "$(register-python-argcomplete dar-backup)"',
37
+ 'eval "$(register-python-argcomplete cleanup)"',
38
+ 'eval "$(register-python-argcomplete manager)"',
39
+ "#complete -o nosort -C 'python -m argcomplete cleanup' cleanup",
40
+ "#complete -o nosort -C 'python -m argcomplete manager' manager",
41
+ end_marker,
42
+ ]) + "\n"
43
+
44
+ # ensure RC file and parent directory exist
45
+ rc_file.parent.mkdir(parents=True, exist_ok=True)
46
+ if not rc_file.exists():
47
+ rc_file.touch()
48
+
49
+ content = rc_file.read_text()
50
+ if marker in content:
51
+ print(f"Autocompletion already installed in {rc_file}")
52
+ return
53
+
54
+ # append the autocompletion block
55
+ rc_file.open("a").write("\n" + block)
56
+ print(f"✔️ Appended autocompletion block to {rc_file}")
57
+
58
+
59
+
60
+ def uninstall_autocompletion() -> str:
61
+ """Remove previously installed autocompletion block from shell RC file."""
62
+ shell = Path(os.environ.get("SHELL", "")).name
63
+ home = Path.home()
64
+
65
+ # pick RC file based on shell
66
+ if shell == "zsh":
67
+ rc_file = home / ".zshrc"
68
+ elif shell == "bash":
69
+ rc_file = home / ".bash_profile" if (home / ".bash_profile").exists() else home / ".bashrc"
70
+ else:
71
+ rc_file = home / ".bashrc"
72
+
73
+ marker = "# >>> dar-backup autocompletion >>>"
74
+ end_marker = "# <<< dar-backup autocompletion <<<"
75
+
76
+ if not rc_file.exists():
77
+ print(f"❌ RC file not found: {rc_file}")
78
+ return
79
+
80
+ content = rc_file.read_text()
81
+ if marker not in content:
82
+ print(f"No autocompletion block found in {rc_file}")
83
+ return f"No autocompletion block found in {rc_file}" # for unit test
84
+
85
+ lines = content.splitlines(keepends=True)
86
+ new_lines = []
87
+ skipping = False
88
+ for line in lines:
89
+ if marker in line:
90
+ skipping = True
91
+ continue
92
+ if end_marker in line and skipping:
93
+ skipping = False
94
+ continue
95
+ if not skipping:
96
+ new_lines.append(line)
97
+
98
+ rc_file.write_text(''.join(new_lines))
99
+ print(f"✔️ Removed autocompletion block from {rc_file}")
12
100
 
13
101
 
14
102
 
@@ -29,6 +117,8 @@ def run_installer(config_file: str, create_db_flag: bool):
29
117
  config_file = os.path.expanduser(os.path.expandvars(config_file))
30
118
  config_settings = ConfigSettings(config_file)
31
119
 
120
+ print(f"Using config settings: {config_settings}")
121
+
32
122
  # Set up logging
33
123
  command_log = config_settings.logfile_location.replace("dar-backup.log", "dar-backup-commands.log")
34
124
  logger = setup_logging(
@@ -50,6 +140,9 @@ def run_installer(config_file: str, create_db_flag: bool):
50
140
  }
51
141
 
52
142
  for name, dir_path in required_dirs.items():
143
+ if not is_safe_path(dir_path):
144
+ logger.error(f"Unsafe path detected: {dir_path} ({name})")
145
+ raise ValueError(f"Unsafe path detected: {dir_path} ({name})")
53
146
  expanded = Path(expand_path(dir_path))
54
147
  if not expanded.exists():
55
148
  logger.info(f"Creating directory: {expanded} ({name})")
@@ -62,22 +155,44 @@ def run_installer(config_file: str, create_db_flag: bool):
62
155
  print(f"Creating catalog for: {backup_def}")
63
156
  result = create_db(backup_def, config_settings, logger, runner)
64
157
  if result == 0:
65
- print(f"✔️ Catalog created (or already existed): {backup_def}")
158
+ print(f"✔️ Catalog created (or already exist): {backup_def}")
66
159
  else:
67
160
  print(f"❌ Failed to create catalog: {backup_def}")
68
161
 
69
162
 
70
163
  def main():
71
164
  parser = argparse.ArgumentParser(description="dar-backup installer")
72
- parser.add_argument("--config", required=True, help="Path to config file")
165
+ parser.add_argument("--config", required=False, help="Path to config file")
73
166
  parser.add_argument("--create-db", action="store_true", help="Create catalog databases")
74
- parser.add_argument("-v", "--version", action="version", version=f"%(prog)s version {about.__version__}, {about.__license__}"
167
+ group = parser.add_mutually_exclusive_group()
168
+ group.add_argument(
169
+ "--install-autocompletion", action="store_true",
170
+ help="Append shell-completion setup to your shell RC"
171
+ )
172
+ group.add_argument(
173
+ "--remove-autocompletion", action="store_true",
174
+ help="Remove shell-completion setup from your shell RC"
175
+ )
176
+ parser.add_argument(
177
+ "-v", "--version", action="version",
178
+ version=f"%(prog)s version {about.__version__}, {about.__license__}"
75
179
  )
76
180
 
77
181
  args = parser.parse_args()
78
182
 
79
- run_installer(args.config, args.create_db)
80
183
 
184
+ if args.config:
185
+ if not os.path.exists(args.config):
186
+ print(f"❌ Config file does not exist: {args.config}")
187
+ return
188
+ run_installer(args.config, args.create_db)
189
+
190
+ if args.install_autocompletion:
191
+ install_autocompletion()
192
+ elif args.remove_autocompletion:
193
+ uninstall_autocompletion()
194
+
195
+
81
196
 
82
197
  if __name__ == "__main__":
83
198
  main()
dar_backup/manager.py CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
3
 
3
4
  """
4
5
  Copyright (C) 2024 Per Jensen
@@ -39,6 +40,7 @@ from dar_backup.util import get_binary_info
39
40
  from dar_backup.util import show_version
40
41
  from dar_backup.util import get_invocation_command_line
41
42
  from dar_backup.util import print_aligned_settings
43
+ from dar_backup.util import show_scriptname
42
44
 
43
45
  from dar_backup.command_runner import CommandRunner
44
46
  from dar_backup.command_runner import CommandResult
@@ -543,14 +545,15 @@ def main():
543
545
  return
544
546
 
545
547
  command_output_log = config_settings.logfile_location.replace("dar-backup.log", "dar-backup-commands.log")
546
- logger = setup_logging(config_settings.logfile_location, command_output_log, args.log_level, args.log_stdout)
548
+ logger = setup_logging(config_settings.logfile_location, command_output_log, args.log_level, args.log_stdout, logfile_max_bytes=config_settings.logfile_max_bytes, logfile_backup_count=config_settings.logfile_backup_count)
547
549
  command_logger = get_logger(command_output_logger=True)
548
550
  runner = CommandRunner(logger=logger, command_logger=command_logger)
549
551
 
550
552
  start_msgs: List[Tuple[str, str]] = []
551
553
 
552
554
  start_time = int(time())
553
- start_msgs.append((f"{SCRIPTNAME}:", about.__version__))
555
+
556
+ start_msgs.append((f"{show_scriptname()}:", about.__version__))
554
557
  logger.info(f"START TIME: {start_time}")
555
558
  logger.debug(f"Command line: {get_invocation_command_line()}")
556
559
  logger.debug(f"`args`:\n{args}")
@@ -558,13 +561,15 @@ def main():
558
561
  start_msgs.append(("Config file:", args.config_file))
559
562
  args.verbose and start_msgs.append(("Backup dir:", config_settings.backup_dir))
560
563
  start_msgs.append(("Logfile:", config_settings.logfile_location))
564
+ args.verbose and start_msgs.append(("Logfile max size (bytes):", config_settings.logfile_max_bytes))
565
+ args.verbose and start_msgs.append(("Logfile backup count:", config_settings.logfile_backup_count))
561
566
  args.verbose and start_msgs.append(("--alternate-archive-dir:", args.alternate_archive_dir))
562
- args.verbose and start_msgs.append(("--cleanup-specific-archives:", args.cleanup_specific_archives))
567
+ args.verbose and start_msgs.append(("--remove-specific-archive:", args.remove_specific_archive))
563
568
  dar_manager_properties = get_binary_info(command='dar_manager')
564
569
  start_msgs.append(("dar_manager:", dar_manager_properties['path']))
565
570
  start_msgs.append(("dar_manager v.:", dar_manager_properties['version']))
566
571
 
567
- print_aligned_settings(start_msgs)
572
+ print_aligned_settings(start_msgs, quiet=not args.verbose)
568
573
 
569
574
  # --- Sanity checks ---
570
575
  if args.add_dir and not args.add_dir.strip():
@@ -1,3 +1,6 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: GPL-3.0-or-later
3
+
1
4
  import os
2
5
  import time
3
6
  from threading import Event
@@ -99,3 +102,4 @@ def show_log_driven_bar(log_path: str, stop_event: Event, session_marker: str, m
99
102
  if stop_event.is_set():
100
103
  break
101
104
 
105
+ # Rich prints a \n here, I will live with it
dar_backup/util.py CHANGED
@@ -1,3 +1,5 @@
1
+ # SPDX-License-Identifier: GPL-3.0-or-later
2
+
1
3
  """
2
4
  util.py source code is here: https://github.com/per2jensen/dar-backup
3
5
 
@@ -10,7 +12,9 @@ See section 15 and section 16 in the supplied "LICENSE" file
10
12
  import typing
11
13
  import locale
12
14
  import configparser
15
+ import inspect
13
16
  import logging
17
+
14
18
  import os
15
19
  import re
16
20
  import subprocess
@@ -19,11 +23,15 @@ import shutil
19
23
  import sys
20
24
  import threading
21
25
  import traceback
26
+
27
+ import dar_backup.__about__ as about
28
+
29
+
22
30
  from argcomplete.completers import ChoicesCompleter
23
31
  from datetime import datetime
24
32
  from dar_backup.config_settings import ConfigSettings
25
- import dar_backup.__about__ as about
26
-
33
+ from logging.handlers import RotatingFileHandler
34
+ from pathlib import Path
27
35
  from rich.console import Console
28
36
  from rich.text import Text
29
37
 
@@ -34,7 +42,16 @@ from typing import Tuple
34
42
  logger=None
35
43
  secondary_logger=None
36
44
 
37
- def setup_logging(log_file: str, command_output_log_file: str, log_level: str = "info", log_to_stdout: bool = False) -> logging.Logger:
45
+ #def setup_logging(log_file: str, command_output_log_file: str, log_level: str = "info", log_to_stdout: bool = False) -> logging.Logger:
46
+ def setup_logging(
47
+ log_file: str,
48
+ command_output_log_file: str,
49
+ log_level: str = "info",
50
+ log_to_stdout: bool = False,
51
+ logfile_max_bytes: int = 26214400,
52
+ logfile_backup_count: int = 5,
53
+ ) -> logging.Logger:
54
+
38
55
  """
39
56
  Sets up logging for the main program and a separate secondary logfile for command outputs.
40
57
 
@@ -43,9 +60,11 @@ def setup_logging(log_file: str, command_output_log_file: str, log_level: str =
43
60
  command_output_log_file (str): The path to the secondary log file for command outputs.
44
61
  log_level (str): The log level to use. Can be "info", "debug", or "trace". Defaults to "info".
45
62
  log_to_stdout (bool): If True, log messages will be printed to the console. Defaults to False.
63
+ logfile_max_bytes: max file size of a log file, defailt = 26214400.
64
+ logfile_backup_count: max numbers of logs files, default = 5.
46
65
 
47
66
  Returns:
48
- None
67
+ a RotatingFileHandler logger instance.
49
68
 
50
69
  Raises:
51
70
  Exception: If an error occurs during logging initialization
@@ -61,20 +80,34 @@ def setup_logging(log_file: str, command_output_log_file: str, log_level: str =
61
80
 
62
81
  logging.Logger.trace = trace
63
82
 
83
+ file_handler = RotatingFileHandler(
84
+ log_file,
85
+ maxBytes=logfile_max_bytes,
86
+ backupCount=logfile_backup_count,
87
+ encoding="utf-8",
88
+ )
89
+
90
+ command_handler = RotatingFileHandler(
91
+ command_output_log_file,
92
+ maxBytes=logfile_max_bytes,
93
+ backupCount=logfile_backup_count,
94
+ encoding="utf-8",
95
+ )
96
+
97
+ formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
98
+ file_handler.setFormatter(formatter)
99
+ command_handler.setFormatter(formatter)
100
+
101
+
64
102
  # Setup main logger
65
103
  logger = logging.getLogger("main_logger")
66
104
  logger.setLevel(logging.DEBUG if log_level == "debug" else TRACE_LEVEL_NUM if log_level == "trace" else logging.INFO)
67
- formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
68
- file_handler = logging.FileHandler(log_file)
69
- file_handler.setFormatter(formatter)
70
105
  logger.addHandler(file_handler)
71
106
 
72
107
  # Setup secondary logger for command outputs
73
108
  secondary_logger = logging.getLogger("command_output_logger")
74
109
  secondary_logger.setLevel(logging.DEBUG if log_level == "debug" else TRACE_LEVEL_NUM if log_level == "trace" else logging.INFO)
75
- sec_file_handler = logging.FileHandler(command_output_log_file)
76
- sec_file_handler.setFormatter(formatter)
77
- secondary_logger.addHandler(sec_file_handler)
110
+ secondary_logger.addHandler(command_handler)
78
111
 
79
112
  if log_to_stdout:
80
113
  stdout_handler = logging.StreamHandler(sys.stdout)
@@ -119,6 +152,15 @@ completer_logger = _setup_completer_logger()
119
152
  completer_logger.debug("Completer logger initialized.")
120
153
 
121
154
 
155
+ def print_debug(msg):
156
+ """
157
+ Print a debug message with the filename and line number of the caller.
158
+ """
159
+ frame = inspect.currentframe().f_back
160
+ print(f"[DEBUG] {frame.f_code.co_filename}:{frame.f_lineno} - {repr(msg)}")
161
+
162
+
163
+
122
164
  def get_invocation_command_line() -> str:
123
165
  """
124
166
  Safely retrieves the exact command line used to invoke the current Python process.
@@ -141,6 +183,17 @@ def get_invocation_command_line() -> str:
141
183
  return f"[error: could not read /proc/[pid]/cmdline: {e}]"
142
184
 
143
185
 
186
+ def show_scriptname() -> str:
187
+ """
188
+ Return script name, useful in start banner for example
189
+ """
190
+ try:
191
+ scriptname = os.path.basename(sys.argv[0])
192
+ except:
193
+ scriptname = "unknown"
194
+ return scriptname
195
+
196
+
144
197
  def show_version():
145
198
  script_name = os.path.basename(sys.argv[0])
146
199
  print(f"{script_name} {about.__version__}")
@@ -606,6 +659,7 @@ def print_aligned_settings(
606
659
  settings: List[Tuple[str, str]],
607
660
  log: bool = True,
608
661
  header: str = "Startup Settings",
662
+ quiet: bool = True,
609
663
  highlight_keywords: List[str] = None
610
664
  ) -> None:
611
665
  """
@@ -624,7 +678,7 @@ def print_aligned_settings(
624
678
  header_line = f"========== {header} =========="
625
679
  footer_line = "=" * len(header_line)
626
680
 
627
- console.print(f"[bold cyan]{header_line}[/bold cyan]")
681
+ not quiet and console.print(f"[bold cyan]{header_line}[/bold cyan]")
628
682
  if log and logger:
629
683
  logger.info(header_line)
630
684
 
@@ -654,13 +708,53 @@ def print_aligned_settings(
654
708
 
655
709
  line_text.append(text, style="white")
656
710
 
657
- console.print(line_text)
711
+ not quiet and console.print(line_text)
658
712
 
659
713
  # Always log clean text (no [!] in log)
660
714
  final_line_for_log = f"{padded_label} {text}"
661
715
  if log and logger:
662
716
  logger.info(final_line_for_log)
663
717
 
664
- console.print(f"[bold cyan]{footer_line}[/bold cyan]")
718
+ not quiet and console.print(f"[bold cyan]{footer_line}[/bold cyan]")
665
719
  if log and logger:
666
720
  logger.info(footer_line)
721
+
722
+
723
+
724
+
725
+ def normalize_dir(path: str) -> str:
726
+ """
727
+ Strip any trailing slash/backslash but leave root (“/” or “C:\\”) intact.
728
+ """
729
+ p = Path(path)
730
+ # Path(__str__) drops any trailing separators
731
+ normalized = str(p)
732
+ return normalized
733
+
734
+
735
+
736
+ # Reusable pattern for archive file naming
737
+ archive_pattern = re.compile(
738
+ r'^.+?_(FULL|DIFF|INCR)_(\d{4}-\d{2}-\d{2})\.\d+\.dar'
739
+ r'(?:\.vol\d+(?:\+\d+)?\.par2|\.par2)?$'
740
+ )
741
+
742
+ def is_safe_filename(filename: str) -> bool:
743
+ """
744
+ Validates that the filename matches acceptable dar/par2 naming convention.
745
+ """
746
+ return archive_pattern.match(filename) is not None
747
+
748
+ def is_safe_path(path: str) -> bool:
749
+ """
750
+ Validates that the full path is absolute, has no '..'.
751
+ """
752
+ normalized = os.path.normpath(path)
753
+ filename = os.path.basename(normalized)
754
+
755
+ return (
756
+ os.path.isabs(normalized)
757
+ and '..' not in normalized.split(os.sep)
758
+ )
759
+
760
+