dar-backup 0.6.10__py3-none-any.whl → 0.6.12__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/__about__.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.6.10"
1
+ __version__ = "0.6.12"
dar_backup/clean_log.py CHANGED
@@ -122,7 +122,7 @@ def main():
122
122
 
123
123
  args = parser.parse_args()
124
124
 
125
- config_settings = ConfigSettings(os.path.expanduser(args.config_file))
125
+ config_settings = ConfigSettings(os.path.expanduser(os.path.expandvars(args.config_file)))
126
126
 
127
127
  if not args.file:
128
128
  args.file = [config_settings.logfile_location]
dar_backup/cleanup.py CHANGED
@@ -171,7 +171,7 @@ def main():
171
171
  parser.add_argument('--log-stdout', action='store_true', help='also print log messages to stdout')
172
172
  args = parser.parse_args()
173
173
 
174
- args.config_file = os.path.expanduser(args.config_file)
174
+ args.config_file = os.path.expanduser(os.path.expandvars(args.config_file))
175
175
 
176
176
 
177
177
  if args.version:
@@ -1,8 +1,11 @@
1
- from dataclasses import dataclass, field
1
+
2
2
  import configparser
3
- from pathlib import Path
4
- import sys
5
3
  import logging
4
+ import sys
5
+
6
+ from dataclasses import dataclass, field, fields
7
+ from os.path import expandvars, expanduser
8
+ from pathlib import Path
6
9
 
7
10
  @dataclass
8
11
  class ConfigSettings:
@@ -20,21 +23,35 @@ class ConfigSettings:
20
23
  backup_d_dir (str): The directory for backup.d.
21
24
  diff_age (int): The age for differential backups before deletion.
22
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.
23
28
  """
24
29
 
25
- def __init__(self, config_file: str):
30
+ config_file: str
31
+ logfile_location: str = field(init=False)
32
+ max_size_verification_mb: int = field(init=False)
33
+ min_size_verification_mb: int = field(init=False)
34
+ no_files_verification: int = field(init=False)
35
+ command_timeout_secs: int = field(init=False)
36
+ backup_dir: str = field(init=False)
37
+ test_restore_dir: str = field(init=False)
38
+ backup_d_dir: str = field(init=False)
39
+ diff_age: int = field(init=False)
40
+ incr_age: int = field(init=False)
41
+ error_correction_percent: int = field(init=False)
42
+ par2_enabled: bool = field(init=False)
43
+
44
+ def __post_init__(self):
26
45
  """
27
- Initializes the ConfigSettings instance by reading the specified configuration file.
28
-
29
- Args:
30
- config_file (str): The path to the configuration file.
46
+ Initializes the ConfigSettings instance by reading the specified configuration file
47
+ and expands environment variables for all string fields.
31
48
  """
32
- if config_file is None:
49
+ if self.config_file is None:
33
50
  raise ValueError("`config_file` must be specified.")
34
51
 
35
52
  self.config = configparser.ConfigParser()
36
53
  try:
37
- self.config.read(config_file)
54
+ self.config.read(self.config_file)
38
55
  self.logfile_location = self.config['MISC']['LOGFILE_LOCATION']
39
56
  self.max_size_verification_mb = int(self.config['MISC']['MAX_SIZE_VERIFICATION_MB'])
40
57
  self.min_size_verification_mb = int(self.config['MISC']['MIN_SIZE_VERIFICATION_MB'])
@@ -46,12 +63,18 @@ class ConfigSettings:
46
63
  self.diff_age = int(self.config['AGE']['DIFF_AGE'])
47
64
  self.incr_age = int(self.config['AGE']['INCR_AGE'])
48
65
  self.error_correction_percent = int(self.config['PAR2']['ERROR_CORRECTION_PERCENT'])
49
- self.par2_enabled = bool(self.config['PAR2']['ENABLED'])
66
+ self.par2_enabled = self.config['PAR2']['ENABLED'].lower() in ('true', '1', 'yes')
67
+
50
68
  # Ensure the directories exist
51
69
  Path(self.backup_dir).mkdir(parents=True, exist_ok=True)
52
70
  Path(self.test_restore_dir).mkdir(parents=True, exist_ok=True)
53
71
  Path(self.backup_d_dir).mkdir(parents=True, exist_ok=True)
54
72
 
73
+ # Expand environment variables for all string fields
74
+ for field in fields(self):
75
+ if isinstance(getattr(self, field.name), str):
76
+ setattr(self, field.name, expanduser(expandvars(getattr(self, field.name))))
77
+
55
78
  except FileNotFoundError as e:
56
79
  logging.error(f"Configuration file not found: {self.config_file}")
57
80
  logging.error(f"Error details: {e}")
@@ -0,0 +1,33 @@
1
+ # This config file is intended to demo `dar-backup`.
2
+ #
3
+ # The `installer` puts it in ~/.config/dar-backup/dar-backup.conf
4
+
5
+ [MISC]
6
+ LOGFILE_LOCATION = ~/dar-backup/dar-backup.log
7
+ MAX_SIZE_VERIFICATION_MB = 20
8
+ MIN_SIZE_VERIFICATION_MB = 1
9
+ NO_FILES_VERIFICATION = 5
10
+ # timeout in seconds for backup, test, restore and par2 operations
11
+ # The author has such `dar` tasks running for 10-15 hours on the yearly backups, so a value of 24 hours is used.
12
+ # If a timeout is not specified when using the util.run_command(), a default timeout of 30 secs is used.
13
+ COMMAND_TIMEOUT_SECS = 86400
14
+
15
+ [DIRECTORIES]
16
+ BACKUP_DIR = ~/dar-backup/backups
17
+ BACKUP.D_DIR = ~/.config/dar-backup/backup.d/
18
+ TEST_RESTORE_DIR = ~/dar-backup/restore/
19
+
20
+ [AGE]
21
+ # age settings are in days
22
+ DIFF_AGE = 100
23
+ INCR_AGE = 40
24
+
25
+ [PAR2]
26
+ ERROR_CORRECTION_PERCENT = 5
27
+ ENABLED = True
28
+
29
+ [PREREQ]
30
+ #SCRIPT_1 = <pre-script 1>
31
+
32
+ [POSTREQ]
33
+ #SCRIPT_1 = <post-script 1>
dar_backup/dar_backup.py CHANGED
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env python3
2
2
 
3
3
  import argparse
4
- import datetime
5
4
  import filecmp
6
5
 
7
6
  import os
@@ -9,13 +8,16 @@ import random
9
8
  import re
10
9
  import shlex
11
10
  import subprocess
12
- import sys
13
11
  import xml.etree.ElementTree as ET
14
12
 
15
13
 
16
14
  from argparse import ArgumentParser
17
15
  from datetime import datetime
18
16
  from pathlib import Path
17
+ from sys import exit
18
+ from sys import stderr
19
+ from sys import argv
20
+ from sys import version_info
19
21
  from time import time
20
22
  from typing import List
21
23
 
@@ -81,30 +83,31 @@ def generic_backup(type: str, command: List[str], backup_file: str, backup_defin
81
83
  raise BackupError(f"Unexpected error during backup: {e}") from e
82
84
 
83
85
 
84
- # Function to recursively find <File> tags and build their full paths
85
- def find_files_with_paths(element: ET, current_path=""):
86
+
87
+ def find_files_with_paths(xml_root: ET.Element):
86
88
  """
87
- Recursively finds files within a directory element and returns a list of file paths with their sizes.
89
+ Finds files within an XML element and returns a list of file paths with their sizes.
88
90
 
89
91
  Args:
90
- element (Element): The directory element to search within.
91
- current_path (str, optional): The current path of the directory element. Defaults to "".
92
+ xml_root (Element): The root element of the XML.
92
93
 
93
94
  Returns:
94
95
  list: A list of tuples containing file paths and their sizes.
95
96
  """
96
- logger.debug(f"Recursively generate list of tuples with file paths and sizes for File elements in dar xml output")
97
+ logger.debug("Generating list of tuples with file paths and sizes for File elements in dar xml output")
97
98
  files = []
98
- if element.tag == "Directory":
99
- current_path = f"{current_path}/{element.get('name')}"
100
- for child in element:
101
- if child.tag == "File":
102
- file_path = (f"{current_path}/{child.get('name')}", child.get('size')) # tuple (filepath, size)
99
+ current_path = []
100
+
101
+ for elem in xml_root.iter():
102
+ if elem.tag == "directory":
103
+ current_path.append(elem.get('name'))
104
+ elif elem.tag == "file":
105
+ file_path = ("/".join(current_path + [elem.get('name')]), elem.get('size'))
103
106
  files.append(file_path)
104
- elif child.tag == "Directory":
105
- files.extend(find_files_with_paths(child, current_path))
106
- return files
107
+ elif elem.tag == "directory" and elem.get('name') in current_path:
108
+ current_path.pop()
107
109
 
110
+ return files
108
111
 
109
112
 
110
113
  def find_files_between_min_and_max_size(backed_up_files: list[(str, str)], config_settings: ConfigSettings):
@@ -238,20 +241,20 @@ def restore_backup(backup_name: str, config_settings: ConfigSettings, restore_di
238
241
  restore_dir (str): The directory where the backup should be restored to.
239
242
  selection (str, optional): A selection criteria to restore specific files or directories. Defaults to None.
240
243
  """
241
- backup_file = os.path.join(config_settings.backup_dir, backup_name)
242
- command = ['dar', '-x', backup_file, '-Q', '-D']
243
- if restore_dir:
244
- if not os.path.exists(restore_dir):
245
- os.makedirs(restore_dir)
246
- command.extend(['-R', restore_dir])
247
- else:
248
- raise RestoreError("Restore directory ('-R <dir>') not specified")
249
- if selection:
250
- selection_criteria = shlex.split(selection)
251
- command.extend(selection_criteria)
252
- command.extend(['-B', darrc, 'restore-options']) # the .darrc `restore-options` section
253
- logger.info(f"Running restore command: {' '.join(map(shlex.quote, command))}")
254
244
  try:
245
+ backup_file = os.path.join(config_settings.backup_dir, backup_name)
246
+ command = ['dar', '-x', backup_file, '-Q', '-D']
247
+ if restore_dir:
248
+ if not os.path.exists(restore_dir):
249
+ os.makedirs(restore_dir)
250
+ command.extend(['-R', restore_dir])
251
+ else:
252
+ raise RestoreError("Restore directory ('-R <dir>') not specified")
253
+ if selection:
254
+ selection_criteria = shlex.split(selection)
255
+ command.extend(selection_criteria)
256
+ command.extend(['-B', darrc, 'restore-options']) # the .darrc `restore-options` section
257
+ logger.info(f"Running restore command: {' '.join(map(shlex.quote, command))}")
255
258
  process = run_command(command, config_settings.command_timeout_secs)
256
259
  if process.returncode == 0:
257
260
  logger.info(f"Restore completed successfully to: '{restore_dir}'")
@@ -260,6 +263,9 @@ def restore_backup(backup_name: str, config_settings: ConfigSettings, restore_di
260
263
  raise RestoreError(str(process))
261
264
  except subprocess.CalledProcessError as e:
262
265
  raise RestoreError(f"Restore command failed: {e}") from e
266
+ except OSError as e:
267
+ logger.error(f"Failed to create restore directory: {e}")
268
+ raise RestoreError("Could not create restore directory")
263
269
  except Exception as e:
264
270
  raise RestoreError(f"Unexpected error during restore: {e}") from e
265
271
 
@@ -285,7 +291,7 @@ def get_backed_up_files(backup_name: str, backup_dir: str):
285
291
  # Parse the XML data
286
292
  root = ET.fromstring(process.stdout)
287
293
  output = None # help gc
288
- # Extract full paths and file size for all <File> elements
294
+ # Extract full paths and file size for all <file> elements
289
295
  file_paths = find_files_with_paths(root)
290
296
  root = None # help gc
291
297
  logger.trace(str(process))
@@ -404,7 +410,7 @@ def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, ba
404
410
  logger.info(f"Using alternate reference archive: {latest_base_backup}")
405
411
  if not os.path.exists(latest_base_backup + '.1.dar'):
406
412
  logger.error(f"Alternate reference archive: \"{latest_base_backup}.1.dar\" does not exist, exiting.")
407
- sys.exit(1)
413
+ exit(1)
408
414
  else:
409
415
  base_backups = sorted(
410
416
  [f for f in os.listdir(config_settings.backup_dir) if f.startswith(f"{backup_definition}_{base_backup_type}_") and f.endswith('.1.dar')],
@@ -489,7 +495,7 @@ def generate_par2_files(backup_file: str, config_settings: ConfigSettings, args)
489
495
 
490
496
 
491
497
  def show_version():
492
- script_name = os.path.basename(sys.argv[0])
498
+ script_name = os.path.basename(argv[0])
493
499
  print(f"{script_name} {about.__version__}")
494
500
  print(f"dar-backup.py source code is here: https://github.com/per2jensen/dar-backup")
495
501
  print('''Licensed under GNU GENERAL PUBLIC LICENSE v3, see the supplied file "LICENSE" for details.
@@ -577,9 +583,9 @@ def main():
577
583
  global logger
578
584
 
579
585
  MIN_PYTHON_VERSION = (3, 9)
580
- if sys.version_info < MIN_PYTHON_VERSION:
581
- sys.stderr.write(f"Error: This script requires Python {'.'.join(map(str, MIN_PYTHON_VERSION))} or higher.\n")
582
- sys.exit(1)
586
+ if version_info < MIN_PYTHON_VERSION:
587
+ stderr.write(f"Error: This script requires Python {'.'.join(map(str, MIN_PYTHON_VERSION))} or higher.\n")
588
+ exit(1)
583
589
 
584
590
  parser = argparse.ArgumentParser(description="Backup and verify using dar backup definitions.")
585
591
  parser.add_argument('-F', '--full-backup', action='store_true', help="Perform a full backup.")
@@ -603,15 +609,25 @@ def main():
603
609
  parser.add_argument('-v', '--version', action='store_true', help="Show version and license information.")
604
610
  args = parser.parse_args()
605
611
 
606
- args.config_file = os.path.expanduser(args.config_file)
607
- config_settings = ConfigSettings(args.config_file)
608
-
609
612
  if args.version:
610
613
  show_version()
611
- sys.exit(0)
614
+ exit(0)
612
615
  elif args.examples:
613
616
  show_examples()
614
- sys.exit(0)
617
+ exit(0)
618
+
619
+ if not args.config_file:
620
+ print(f"Config file not specified, exiting", file=stderr)
621
+ exit(1)
622
+
623
+ config_settings_path = os.path.expanduser(os.path.expandvars(args.config_file))
624
+ if not os.path.exists(config_settings_path):
625
+ print(f"Config file {args.config_file} does not exist.", file=stderr)
626
+ exit(127)
627
+
628
+ args.config_file = config_settings_path
629
+ config_settings = ConfigSettings(args.config_file)
630
+
615
631
 
616
632
  logger = setup_logging(config_settings.logfile_location, args.log_level, args.log_stdout)
617
633
 
@@ -619,10 +635,13 @@ def main():
619
635
  current_script_dir = os.path.dirname(os.path.abspath(__file__))
620
636
  args.darrc = os.path.join(current_script_dir, ".darrc")
621
637
 
622
- if os.path.exists(args.darrc) and os.path.isfile(args.darrc):
638
+ darrc_file = os.path.expanduser(os.path.expandvars(args.darrc))
639
+ if os.path.exists(darrc_file) and os.path.isfile(darrc_file):
623
640
  logger.debug(f"Using .darrc: {args.darrc}")
624
641
  else:
625
- logger.error(f"Supplied .darrc: '{args.darrc}' does not exist or is not a file")
642
+ logger.error(f"Supplied .darrc: '{args.darrc}' does not exist or is not a file, exiting", file=stderr)
643
+ exit(127)
644
+
626
645
 
627
646
 
628
647
  try:
@@ -657,10 +676,10 @@ def main():
657
676
  # sanity check
658
677
  if args.backup_definition and not os.path.exists(os.path.join(config_settings.backup_d_dir, args.backup_definition)):
659
678
  logger.error(f"Backup definition: '{args.backup_definition}' does not exist, exiting")
660
- sys.exit(1)
679
+ exit(127)
661
680
  if args.backup_definition and '_' in args.backup_definition:
662
681
  logger.error(f"Backup definition: '{args.backup_definition}' contains '_', exiting")
663
- sys.exit(1)
682
+ exit(1)
664
683
 
665
684
 
666
685
  requirements('PREREQ', config_settings)
@@ -684,12 +703,12 @@ def main():
684
703
  requirements('POSTREQ', config_settings)
685
704
 
686
705
  args.verbose and print("\033[1m\033[32mSUCCESS\033[0m No errors encountered")
687
- sys.exit(0)
706
+ exit(0)
688
707
  except Exception as e:
689
708
  logger.exception("An error occurred")
690
709
  logger.error("Exception details:", exc_info=True)
691
710
  args.verbose and print("\033[1m\033[31mErrors\033[0m encountered")
692
- sys.exit(1)
711
+ exit(1)
693
712
  finally:
694
713
  end_time=int(time())
695
714
  logger.info(f"END TIME: {end_time}")
@@ -0,0 +1,129 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ installer.py source code is here: https://github.com/per2jensen/dar-backup/tree/main/v2/src/dar_backup/installer.py
4
+ This script is part of dar-backup, a backup solution for Linux using dar and systemd.
5
+
6
+ Licensed under GNU GENERAL PUBLIC LICENSE v3, see the supplied file "LICENSE" for details.
7
+
8
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW,
9
+ not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
10
+ See section 15 and section 16 in the supplied "LICENSE" file
11
+
12
+ This script can be used to configure dar-backup on your system.
13
+ It is non-destructive and will not overwrite any existing files or directories.
14
+ """
15
+
16
+ import argparse
17
+ import os
18
+ import shutil
19
+ import sys
20
+
21
+ from . import __about__ as about
22
+ from pathlib import Path
23
+
24
+ LICENSE = '''Licensed under GNU GENERAL PUBLIC LICENSE v3, see the supplied file "LICENSE" for details.
25
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW, not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
26
+ See section 15 and section 16 in the supplied "LICENSE" file.'''
27
+
28
+ CONFIG_DIR = os.path.expanduser("~/.config/dar-backup")
29
+ DAR_BACKUP_DIR = os.path.expanduser("~/dar-backup/")
30
+
31
+ BACKUP_DEFINITION = '''
32
+ # Demo of a `dar-backup` definition file
33
+ # This back definition file configures a backup of ~/.config/dar-backup
34
+ # `dar-backup` puts the backups in ~/dar-backup/backups
35
+ # ------------------------------------------------------------------------
36
+
37
+ # Switch to ordered selection mode, which means that the following options
38
+ # will be considered top to bottom
39
+ -am
40
+
41
+ # Backup Root dir
42
+ -R @@HOME_DIR@@
43
+
44
+ # Directories to backup below the Root dir
45
+ -g .config/dar-backup
46
+
47
+ # Examples of directories to exclude below the Root dir
48
+ -P mnt
49
+ -P .private
50
+ -P .cache
51
+
52
+ # compression level
53
+ -z5
54
+
55
+ # no overwrite, if you rerun a backup, 'dar' halts and asks what to do
56
+ -n
57
+
58
+ # size of each slice in the archive
59
+ --slice 10G
60
+
61
+ # bypass directores marked as cache directories
62
+ # http://dar.linux.free.fr/doc/Features.html
63
+ --cache-directory-tagging
64
+ '''
65
+
66
+ def main():
67
+ parser = argparse.ArgumentParser(
68
+ description="Set up `dar-backup` on your system.",
69
+ )
70
+ parser.add_argument(
71
+ "-i", "--install",
72
+ action="store_true",
73
+ help="Deploy a simple config file, use ~/dar-backup/ for log file, archives and restore tests."
74
+ )
75
+
76
+ parser.add_argument(
77
+ "--dry-run",
78
+ action="store_true",
79
+ help="Show which lines would be removed without modifying the file."
80
+ )
81
+
82
+
83
+ parser.add_argument(
84
+ "-v", "--version",
85
+ action="version",
86
+ version=f"%(prog)s version {about.__version__}, {LICENSE}"
87
+ )
88
+
89
+ args = parser.parse_args()
90
+
91
+ if args.install:
92
+ errors = []
93
+ if os.path.exists(CONFIG_DIR):
94
+ errors.append(f"Config directory '{CONFIG_DIR}' already exists.")
95
+
96
+ if os.path.exists(DAR_BACKUP_DIR):
97
+ errors.append(f"Directory '{DAR_BACKUP_DIR}' already exists.")
98
+
99
+ if len(errors) > 0:
100
+ for error in errors:
101
+ print(f"Error: {error}")
102
+ sys.exit(1)
103
+
104
+ os.makedirs(DAR_BACKUP_DIR, exist_ok=False)
105
+ os.makedirs(os.path.join(DAR_BACKUP_DIR, "backups"), exist_ok=False)
106
+ os.makedirs(os.path.join(DAR_BACKUP_DIR, "restore"), exist_ok=False)
107
+ os.makedirs(CONFIG_DIR, exist_ok=False)
108
+ os.makedirs(os.path.join(CONFIG_DIR, "backup.d"), exist_ok=False)
109
+ print(f"Directories created: `{DAR_BACKUP_DIR}` and `{CONFIG_DIR}`")
110
+
111
+ script_dir = Path(__file__).parent
112
+ source_file = script_dir / "dar-backup.conf"
113
+ destination_file = Path(CONFIG_DIR) / "dar-backup.conf"
114
+ shutil.copy(source_file, destination_file)
115
+ print(f"Config file deployed to {destination_file}")
116
+
117
+
118
+ backup_definition = BACKUP_DEFINITION.replace("@@HOME_DIR@@", os.path.expanduser("~"))
119
+ with open(os.path.join(CONFIG_DIR, "backup.d", "default"), "w") as f:
120
+ f.write(backup_definition)
121
+ print(f"Default backup definition file deployed to {os.path.join(CONFIG_DIR, 'backup.d', 'default')}")
122
+ print("1. Now run `manager --create` to create the catalog database.")
123
+ print("2. Then you can run `dar-backup --full-backup` to create a backup.")
124
+ print("3. List backups with `dar-backup --list`")
125
+ print("4. List contents of a backup with `dar-backup --list-contents <backup-name>`")
126
+
127
+
128
+ if __name__ == "__main__":
129
+ main()
dar_backup/manager.py CHANGED
@@ -395,7 +395,7 @@ See section 15 and section 16 in the supplied "LICENSE" file.''')
395
395
  sys.exit(0)
396
396
 
397
397
  # setup logging
398
- args.config_file = os.path.expanduser(args.config_file)
398
+ args.config_file = os.path.expanduser(os.path.expandvars(args.config_file))
399
399
  config_settings = ConfigSettings(args.config_file)
400
400
  if not os.path.dirname(config_settings.logfile_location):
401
401
  print(f"Directory for log file '{config_settings.logfile_location}' does not exist, exiting")
dar_backup/util.py CHANGED
@@ -14,6 +14,7 @@ import os
14
14
  import re
15
15
  import subprocess
16
16
  import shlex
17
+ import shutil
17
18
  import sys
18
19
  import threading
19
20
  import traceback
@@ -123,10 +124,113 @@ def _stream_reader(pipe, log_func, output_accumulator: List[str]):
123
124
  log_func(stripped_line) # Log the output in real time
124
125
 
125
126
 
127
+
126
128
  def run_command(command: List[str], timeout: int = 30) -> CommandResult:
127
129
  """
128
130
  Executes a given command via subprocess, logs its output in real time, and returns the result.
129
131
 
132
+ Args:
133
+ command (list): The command to be executed, represented as a list of strings.
134
+ timeout (int): The maximum time in seconds to wait for the command to complete. Defaults to 30 seconds.
135
+
136
+ Returns:
137
+ A CommandResult NamedTuple with the following properties:
138
+ - process: subprocess.CompletedProcess
139
+ - stdout: str: The full standard output of the command.
140
+ - stderr: str: The full standard error of the command.
141
+ - returncode: int: The return code of the command.
142
+ - timeout: int: The timeout value in seconds used to run the command.
143
+ - command: list[str]: The command executed.
144
+
145
+ Logs:
146
+ - Logs standard output (`stdout`) in real-time at the INFO log level.
147
+ - Logs standard error (`stderr`) in real-time at the ERROR log level.
148
+
149
+ Raises:
150
+ subprocess.TimeoutExpired: If the command execution times out (see `timeout` parameter).
151
+ Exception: If other exceptions occur during command execution.
152
+ FileNotFoundError: If the command is not found.
153
+
154
+ Notes:
155
+ - While the command runs, its `stdout` and `stderr` streams are logged in real-time.
156
+ - The returned `stdout` and `stderr` capture the complete output, even though the output is also logged.
157
+ - The command is forcibly terminated if it exceeds the specified timeout.
158
+ """
159
+ stdout_lines = [] # To accumulate stdout
160
+ stderr_lines = [] # To accumulate stderr
161
+ process = None # Track the process for cleanup
162
+ stdout_thread = None
163
+ stderr_thread = None
164
+
165
+ try:
166
+ # Check if the command exists before executing
167
+ if not shutil.which(command[0]):
168
+ raise FileNotFoundError(f"Command not found: {command[0]}")
169
+
170
+ logger.debug(f"Running command: {command}")
171
+ process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
172
+
173
+ # Start threads to read and log stdout and stderr
174
+ stdout_thread = threading.Thread(target=_stream_reader, args=(process.stdout, logger.info, stdout_lines))
175
+ stderr_thread = threading.Thread(target=_stream_reader, args=(process.stderr, logger.error, stderr_lines))
176
+
177
+ stdout_thread.start()
178
+ stderr_thread.start()
179
+
180
+ # Wait for process to complete or timeout
181
+ process.wait(timeout=timeout)
182
+
183
+ except FileNotFoundError as e:
184
+ logger.error(f"Command not found: {command[0]}")
185
+ return CommandResult(
186
+ process=None,
187
+ stdout="",
188
+ stderr=str(e),
189
+ returncode=127,
190
+ timeout=timeout,
191
+ command=command
192
+ )
193
+ except subprocess.TimeoutExpired:
194
+ if process:
195
+ process.terminate()
196
+ logger.error(f"Command: '{command}' timed out and was terminated.")
197
+ raise
198
+ except Exception as e:
199
+ logger.error(f"Error running command: {command}", exc_info=True)
200
+ raise
201
+ finally:
202
+ # Ensure threads are joined to clean up (only if they were started)
203
+ if stdout_thread and stdout_thread.is_alive():
204
+ stdout_thread.join()
205
+ if stderr_thread and stderr_thread.is_alive():
206
+ stderr_thread.join()
207
+
208
+ # Ensure process streams are closed
209
+ if process and process.stdout:
210
+ process.stdout.close()
211
+ if process and process.stderr:
212
+ process.stderr.close()
213
+
214
+ # Combine captured stdout and stderr lines into single strings
215
+ stdout = "\n".join(stdout_lines)
216
+ stderr = "\n".join(stderr_lines)
217
+
218
+ # Build the result object
219
+ result = CommandResult(
220
+ process=process,
221
+ stdout=stdout,
222
+ stderr=stderr,
223
+ returncode=process.returncode,
224
+ timeout=timeout,
225
+ command=command
226
+ )
227
+ logger.debug(f"Command result: {result}")
228
+ return result
229
+
230
+ def run_command2(command: List[str], timeout: int = 30) -> CommandResult:
231
+ """
232
+ Executes a given command via subprocess, logs its output in real time, and returns the result.
233
+
130
234
  Args:
131
235
  command (list): The command to be executed, represented as a list of strings.
132
236
  timeout (int): The maximum time in seconds to wait for the command to complete. Defaults to 30 seconds.
@@ -158,6 +262,10 @@ def run_command(command: List[str], timeout: int = 30) -> CommandResult:
158
262
  process = None # Track the process for cleanup
159
263
 
160
264
  try:
265
+ # Check if the command exists before executing
266
+ if not shutil.which(command[0]):
267
+ raise FileNotFoundError(f"Command not found: {command[0]}")
268
+
161
269
  logger.debug(f"Running command: {command}")
162
270
  process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
163
271
 
@@ -171,6 +279,16 @@ def run_command(command: List[str], timeout: int = 30) -> CommandResult:
171
279
  # Wait for process to complete or timeout
172
280
  process.wait(timeout=timeout)
173
281
 
282
+ except FileNotFoundError as e:
283
+ logger.error(f"Command not found: {command[0]}")
284
+ return CommandResult(
285
+ process=None,
286
+ stdout="",
287
+ stderr=str(e),
288
+ returncode=127,
289
+ timeout=timeout,
290
+ command=command
291
+ )
174
292
  except subprocess.TimeoutExpired:
175
293
  if process:
176
294
  process.terminate()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dar-backup
3
- Version: 0.6.10
3
+ Version: 0.6.12
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: Homepage, https://github.com/per2jensen/dar-backup/tree/main/v2
6
6
  Project-URL: Changelog, https://github.com/per2jensen/dar-backup/blob/main/v2/Changelog.md
@@ -690,9 +690,9 @@ Classifier: Topic :: System :: Archiving :: Backup
690
690
  Requires-Python: >=3.9
691
691
  Description-Content-Type: text/markdown
692
692
 
693
- # Full, differential or incremental backups using 'dar'
693
+ # Full, differential or incremental backups using 'dar'
694
694
 
695
- The wonderful 'dar' [Disk Archiver] (https://github.com/Edrusb/DAR) is used for
695
+ The wonderful 'dar' [Disk Archiver](https://github.com/Edrusb/DAR) is used for
696
696
  the heavy lifting, together with the par2 suite in these scripts.
697
697
 
698
698
  ## Table of Contents
@@ -706,12 +706,11 @@ Description-Content-Type: text/markdown
706
706
  - [Requirements](#requirements)
707
707
  - [Config file](#config-file)
708
708
  - [How to run](#how-to-run)
709
- - [1](#1)
710
- - [2](#2)
711
- - [3](#3)
712
- - [4](#4)
713
- - [5](#5)
714
- - [6](#6)
709
+ - [1 - installation](#1---installation)
710
+ - [2 - configuration](#2---configuration)
711
+ - [3 - generate catalog databases](#3---generate-catalog-databases)
712
+ - [4 - do FULL backups](#4---do-full-backups)
713
+ - [5 - deactivate venv](#5---deactivate-venv)
715
714
  - [.darrc](#darrc)
716
715
  - [Systemctl examples](#systemctl-examples)
717
716
  - [Service: dar-back --incremental-backup](#service-dar-back---incremental-backup)
@@ -723,12 +722,19 @@ Description-Content-Type: text/markdown
723
722
  - [Exclude .xmp files from that date](#exclude-xmp-files-from-that-date)
724
723
  - [Restoring](#restoring)
725
724
  - [Default location for restores](#default-location-for-restores)
726
- - [--restore-dir option](#restore-dir-option)
725
+ - [--restore-dir option](#--restore-dir-option)
727
726
  - [A single file](#a-single-file)
728
727
  - [A directory](#a-directory)
729
728
  - [.NEF from a specific date](#nef-from-a-specific-date)
729
+ - [Restore test fails with exit code 4](#restore-test-fails-with-exit-code-4)
730
+ - [Restore test fails with exit code 5](#restore-test-fails-with-exit-code-5)
731
+ - [Par2](#par2)
732
+ - [Par2 to verify/repair](#par2-to-verifyrepair)
733
+ - [Par2 create redundancy files](#par2-create-redundancy-files)
730
734
  - [Points of interest](#points-of-interest)
735
+ - [Merge FULL with DIFF, creating new FULL](#merge-full-with-diff-creating-new-full)
731
736
  - [dar manager databases](#dar-manager-databases)
737
+ - [Performance tip due to par2](#performance-tip-due-to-par2)
732
738
  - [.darrc sets -vd -vf (since v0.6.4)](#darrc-sets--vd--vf-since-v064)
733
739
  - [Reference](#reference)
734
740
  - [dar-backup.py](#dar-backuppy)
@@ -791,105 +797,9 @@ On Ubuntu, install the requirements this way:
791
797
 
792
798
  The default configuration is expected here: ~/.config/dar-backup/dar-backup.conf
793
799
 
794
- ## How to run
800
+ ## How to run
795
801
 
796
- ### 1
797
-
798
- Config file default location is $HOME/.config/dar-backup/dar-backup.conf
799
-
800
- Example:
801
-
802
- ```` code
803
- [MISC]
804
- LOGFILE_LOCATION=/home/user/dar-backup.log
805
- MAX_SIZE_VERIFICATION_MB = 20
806
- MIN_SIZE_VERIFICATION_MB = 1
807
- NO_FILES_VERIFICATION = 5
808
-
809
- # timeout in seconds for backup, test, restore and par2 operations
810
- # The author has such `dar` tasks running for 10-15 hours on the yearly backups, so a value of 24 hours is used.
811
- # If a timeout is not specified when using the util.run_command(), a default timeout of 30 secs is used.
812
- COMMAND_TIMEOUT_SECS = 86400
813
-
814
- [DIRECTORIES]
815
- BACKUP_DIR = /home/user/mnt/dir/
816
- BACKUP.D_DIR = /home/user/.config/dar-backup/backup.d/
817
- TEST_RESTORE_DIR = /tmp/dar-backup/restore/
818
-
819
- [AGE]
820
- # age settings are in days
821
- DIFF_AGE = 100
822
- INCR_AGE = 40
823
-
824
- [PAR2]
825
- ERROR_CORRECTION_PERCENT = 5
826
- # False means "do not generate par2 redundancy files"
827
- ENABLED = True
828
-
829
- [PREREQ]
830
- # SCRIPT_1 = /home/user/programmer/dar-backup/prereq/mount-server.sh
831
- # SCRIPT_2 = <something>
832
- # ...
833
-
834
- [POSTREQ]
835
- # SCRIPT_1 = /home/user/programmer/dar-backup/postreq/umount-server.sh
836
- # SCRIPT_2 = <something>
837
- # ...
838
-
839
- ````
840
-
841
- ### 2
842
-
843
- Put your backup definitions in the directory $BACKUP.D_DIR (defined in the config file)
844
-
845
- The name of the file is the `backup definition` name.
846
-
847
- Make as many backup definitions as you need. Run them all in one go, or run one at a time using the `-d` option.
848
-
849
- The `dar` [documentation](http://dar.linux.free.fr/doc/man/dar.html#COMMANDS%20AND%20OPTIONS) has good information on file selection.
850
-
851
- Example of backup definition for a home directory
852
-
853
- ```` code
854
-
855
- # Switch to ordered selection mode, which means that the following
856
- # options will be considered top to bottom
857
- -am
858
-
859
-
860
- # Backup Root dir
861
- -R /home/user
862
-
863
- # Directories to backup below the Root dir
864
- # if you want to take a backup of /home/user/Documents only, uncomment next line
865
- # -g Documents
866
-
867
- # Some directories to exclude below the Root dir (here Root directory is `/home/user` as set in the -R option)
868
- -P mnt
869
- -P tmp
870
- -P .cache
871
- -P .config/Code/CachedData
872
- -P .config/Code/Cache
873
- -P ".config/Code/Service Worker"
874
- -P .config/Code/logs
875
- -P snap/firefox/common/.cache
876
-
877
- # compression level
878
- -z5
879
-
880
- # no overwrite, if you rerun a backup, 'dar' halts and asks what to do (and Quits due to the "-Q" given by dar-backup)
881
- -n
882
-
883
- # size of each slice in the archive
884
- --slice 10G
885
-
886
-
887
- # bypass directores marked as cache directories
888
- # http://dar.linux.free.fr/doc/Features.html
889
- --cache-directory-tagging
890
- ````
891
-
892
- ### 3
802
+ ### 1 - installation
893
803
 
894
804
  Installation is currently in a venv. These commands are installed in the venv:
895
805
 
@@ -897,6 +807,7 @@ Installation is currently in a venv. These commands are installed in the venv:
897
807
  - cleanup
898
808
  - manager
899
809
  - clean-log
810
+ - installer
900
811
 
901
812
  To install, create a venv and run pip:
902
813
 
@@ -918,25 +829,48 @@ Typing `db` at the command line gives this
918
829
 
919
830
  ```` bash
920
831
  (venv) user@machine:~$ db
921
- dar-backup 0.6.9
832
+ dar-backup 0.6.12
922
833
  dar-backup.py source code is here: https://github.com/per2jensen/dar-backup
923
834
  Licensed under GNU GENERAL PUBLIC LICENSE v3, see the supplied file "LICENSE" for details.
924
835
  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW, not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
925
836
  See section 15 and section 16 in the supplied "LICENSE" file.
926
837
  ````
927
838
 
928
- ### 4
839
+ ### 2 - configuration
840
+
841
+ The dar-backup installer is non-destructive and stops if some of the default directories exist.
842
+
843
+ Run the installer
844
+
845
+ ```` bash
846
+ installer --install
847
+ ````
848
+
849
+ The output is
850
+
851
+ ```` text
852
+ Directories created: `/home/user/dar-backup/` and `/home/user/.config/dar-backup`
853
+ Config file deployed to /home/user/.config/dar-backup/dar-backup.conf
854
+ Default backup definition deployed to /home/user/.config/dar-backup/backup.d/default
855
+ 1. Now run `manager --create` to create the catalog database.
856
+ 2. Then you can run `dar-backup --full-backup` to create a backup.
857
+ 3. List backups with `dar-backup --list`
858
+ 4. List contents of a backup with `dar-backup --list-contents <backup-name>`
859
+ ````
860
+
861
+ ### 3 - generate catalog databases
862
+
863
+ Generate the archive catalog database(s).
929
864
 
930
- Generate the archive catalog database(s).
931
865
  `dar-backup` expects the catalog databases to be in place, it does not automatically create them (by design)
932
866
 
933
867
  ```` bash
934
- manager --create-db
868
+ manager --create
935
869
  ````
936
870
 
937
- ### 5
871
+ ### 4 - do FULL backups
938
872
 
939
- You are ready to do backups of all your backup definitions, if your backup definitions are
873
+ You are ready to do backups of all your backup definitions, if your backup definitions are
940
874
  in place in BACKUP.D_DIR (see config file)
941
875
 
942
876
  ```` bash
@@ -953,9 +887,9 @@ If you want a backup of a single definition, use the `-d <backup definition>` op
953
887
  dar-backup --full-backup -d <your backup definition>
954
888
  ````
955
889
 
956
- ### 6
890
+ ### 5 - deactivate venv
957
891
 
958
- Deactivate the virtual environment
892
+ Deactivate the virtual environment (venv)
959
893
 
960
894
  ```` bash
961
895
  deactivate
@@ -1311,14 +1245,92 @@ dar-backup --restore <archive_name> --selection "-X '*.xmp' -I '*2024-06-16*' -
1311
1245
  deactivate
1312
1246
  ```
1313
1247
 
1248
+ ### restore test fails with exit code 4
1249
+
1250
+ "dar" in newer versions emits a question about file ownership, which is "answered" with a "no" via the "-Q" option. That in turn leads to an error code 4.
1251
+
1252
+ Thus the dar option "--comparison-field=ignore-owner" has been placed in the supplied .darrc file (located in the virtual environment where dar-backup is installed).
1253
+
1254
+ This causes dar to restore without an error.
1255
+
1256
+ It is a good option when using dar as a non-privileged user.
1257
+
1258
+ ### restore test fails with exit code 5
1259
+
1260
+ If exit code 5 is emitted on the restore test, FSA (File System specific Attributes) could be the cause.
1261
+
1262
+ That (might) occur if you backup a file stored on one type of filesystem, and restore it on another type.
1263
+ My home directory is on a btrfs filesystem, while /tmp (for the restore test) is on zfs.
1264
+
1265
+ The restore test can result in an exit code 5, due to the different filesystems used. In order to avoid the errors, the "option "--fsa-scope none" can be used. That will restult in FSA's not being restored.
1266
+
1267
+ If you need to use this option, un-comment it in the .darrc file (located in the virtual environment where dar-backup is installed)
1268
+
1269
+ ## Par2
1270
+
1271
+ ### Par2 to verify/repair
1272
+
1273
+ You can run a par2 verification on an archive like this:
1274
+
1275
+ ```` bash
1276
+ for file in <archive>*.dar.par2; do
1277
+ par2 verify "$file"
1278
+ done
1279
+ ````
1280
+
1281
+ if there are problems with a slice, try to repair it like this:
1282
+
1283
+ ```` bash
1284
+ par2 repair <archive>.<slice number>.dar.par2
1285
+ ````
1286
+
1287
+ ### Par2 create redundancy files
1288
+
1289
+ If you have merged archives, you will need to create the .par2 redundency files manually.
1290
+ Here is an example
1291
+
1292
+ ```` bash
1293
+ for file in <some-archive>_FULL_yyyy-mm-dd.*; do
1294
+ par2 c -r5 -n1 "$file"
1295
+ done
1296
+ ````
1297
+
1298
+ where "c" is create, -r5 is 5% redundency and -n1 is 1 redundency file
1299
+
1314
1300
  ## Points of interest
1315
1301
 
1302
+ ### Merge FULL with DIFF, creating new FULL
1303
+
1304
+ Over time, the DIFF archives become larger and larger. At some point one wishes to create a new FULL archive to do DIFF's on.
1305
+ One way to do that, is to let dar create a FULL archive from scratch, another is to merge a FULL archive with a DIFF, and from there do DIFF's until they once again gets too large for your taste.
1306
+
1307
+ I do backups of my homedir. Here it is shown how a FULL archive is merged with a DIFF, creating a new FULL archive.
1308
+
1309
+ ```` bash
1310
+ dar --merge pj-homedir_FULL_2021-09-12 -A pj-homedir_FULL_2021-06-06 -@pj-homedir_DIFF_2021-08-29 -s 12G
1311
+
1312
+ # test the new FULL archive
1313
+ dar -t pj-homedir_FULL_2021-09-12
1314
+
1315
+ # create Par2 redundancy files
1316
+ for file in pj-homedir_FULL_yyyy-mm-dd.*.dar; do
1317
+ par2 c -r5 -n1 "$file"
1318
+ done
1319
+
1320
+ ````
1321
+
1316
1322
  ### dar manager databases
1317
1323
 
1318
1324
  `dar-backup` now saves archive catalogs in dar catalog databases.
1319
1325
 
1320
1326
  This makes it easier to restore to a given date when having many FULL, DIFF and INCR archives.
1321
1327
 
1328
+ ### Performance tip due to par2
1329
+
1330
+ This [dar benchmark page](https://dar.sourceforge.io/doc/benchmark.html) has an interesting note on the slice size.
1331
+
1332
+ Slice size should be smaller than available RAM, apparently a large performance hit can be avoided keeping the the par2 data in memory.
1333
+
1322
1334
  ### .darrc sets -vd -vf (since v0.6.4)
1323
1335
 
1324
1336
  These .darrc settings make `dar` print the current directory being processed (-vd) and some stats after (-vf)
@@ -0,0 +1,16 @@
1
+ dar_backup/.darrc,sha256=-aerqivZmOsW_XBCh9IfbYTUvw0GkzDSr3Vx4GcNB1g,2113
2
+ dar_backup/__about__.py,sha256=AY38r3HUSyMqkCPP-vaHASQmjQF5-PRm11j_QYtx28w,22
3
+ dar_backup/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ dar_backup/clean_log.py,sha256=cGhtKYnQJ2ceNQfw5XcCln_WNBasbmlfhO3kRydjDNk,5196
5
+ dar_backup/cleanup.py,sha256=NaZMrTdtYv4uJSw3jwDQWc5F5jMfIdfIQdrcPGAVcnM,11439
6
+ dar_backup/config_settings.py,sha256=uicCq6FnpxPFzbv7xfYSXNnQf1tfLk1Z3VIO9M71fsE,4659
7
+ dar_backup/dar-backup.conf,sha256=-wXqP4vj5TS7cCfMJN1nbk-1Sqkq00Tg22ySQXynUF4,902
8
+ dar_backup/dar_backup.py,sha256=Cye8gS0E0mNKaUzcjqsdsuTyyeZYCspRMBIdcGbsEik,33171
9
+ dar_backup/installer.py,sha256=0TgC_O-T7Y3sLn_NIQ9lBYt8GJqLZzxPqkmbjElfgkM,4491
10
+ dar_backup/manager.py,sha256=MkrB0AL0MefE_cUAvdzQb_0bzvqsPmYi5s0M3EPw-z8,21379
11
+ dar_backup/util.py,sha256=E-sEBQZY1hmdeVx5xNE22zKQ0BXDee1eI9F1-w7Fq1Q,15756
12
+ dar_backup-0.6.12.dist-info/METADATA,sha256=YEbPNJr_ntP03BVKSACo2MU-Qko_9quyzmuqrhryBz0,69768
13
+ dar_backup-0.6.12.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
14
+ dar_backup-0.6.12.dist-info/entry_points.txt,sha256=Z7P5BUbhtJxo8_nB9qNIMay2eGDbsMKB3Fjwv3GMa4g,202
15
+ dar_backup-0.6.12.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
16
+ dar_backup-0.6.12.dist-info/RECORD,,
@@ -2,4 +2,5 @@
2
2
  clean-log = dar_backup.clean_log:main
3
3
  cleanup = dar_backup.cleanup:main
4
4
  dar-backup = dar_backup.dar_backup:main
5
+ installer = dar_backup.installer:main
5
6
  manager = dar_backup.manager:main
@@ -1,14 +0,0 @@
1
- dar_backup/.darrc,sha256=-aerqivZmOsW_XBCh9IfbYTUvw0GkzDSr3Vx4GcNB1g,2113
2
- dar_backup/__about__.py,sha256=G-zfU8sHQRTZnZK0te1cvbxqeLhT1z86XeAYFNUON6Q,22
3
- dar_backup/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- dar_backup/clean_log.py,sha256=VXKA2BMyQmaC6R08Bq9a3wP3mczdFb_moy6HkL-mnF8,5176
5
- dar_backup/cleanup.py,sha256=9yEdRR84XPtEvBGc2QfwGBQl2tdTPttjetHeiSc_TsM,11419
6
- dar_backup/config_settings.py,sha256=CBMUhLOOZ-x7CRdS3vBDk4TYaGqC4N1Ot8IMH-qPaI0,3617
7
- dar_backup/dar_backup.py,sha256=hDy7aXU-XiWOtW40Pxql441liNkSYKGU76eOwy8m7fU,32714
8
- dar_backup/manager.py,sha256=HDa8eYF89QFhlBRR4EWRzzmswOW00S_w8ToZ5SARO_o,21359
9
- dar_backup/util.py,sha256=SSSJYM9lQZfubhTUBlX1xDGWmCpYEF3ePARmlY544xM,11283
10
- dar_backup-0.6.10.dist-info/METADATA,sha256=CEgbqp93sB_cPnEDWuLn7gphZjdrn4zP_5dShC2Buv8,67980
11
- dar_backup-0.6.10.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
12
- dar_backup-0.6.10.dist-info/entry_points.txt,sha256=p6c4uQLjlTIVP1Od2iorGefrVUH0IWZdFRMl63mNaRg,164
13
- dar_backup-0.6.10.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
14
- dar_backup-0.6.10.dist-info/RECORD,,