dar-backup 0.6.16__py3-none-any.whl → 0.6.18__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.16"
1
+ __version__ = "0.6.18"
dar_backup/cleanup.py CHANGED
@@ -27,16 +27,16 @@ from typing import Dict, List, NamedTuple
27
27
 
28
28
  from . import __about__ as about
29
29
  from dar_backup.config_settings import ConfigSettings
30
- from dar_backup.util import extract_error_lines
31
30
  from dar_backup.util import list_backups
32
- from dar_backup.util import run_command
33
31
  from dar_backup.util import setup_logging
32
+ from dar_backup.util import get_logger
33
+ from dar_backup.util import requirements
34
34
 
35
- from dar_backup.util import CommandResult
36
-
37
-
35
+ from dar_backup.command_runner import CommandRunner
36
+ from dar_backup.command_runner import CommandResult
38
37
 
39
38
  logger = None
39
+ runner = None
40
40
 
41
41
  def delete_old_backups(backup_dir, age, backup_type, args, backup_definition=None):
42
42
  """
@@ -134,7 +134,7 @@ def delete_catalog(catalog_name: str, args: NamedTuple) -> bool:
134
134
  command = [f"manager", "--remove-specific-archive", catalog_name, "--config-file", args.config_file, '--log-level', 'debug', '--log-stdout']
135
135
  logger.info(f"Deleting catalog '{catalog_name}' using config file: '{args.config_file}'")
136
136
  try:
137
- result:CommandResult = run_command(command)
137
+ result:CommandResult = runner.run(command)
138
138
  if result.returncode == 0:
139
139
  logger.info(f"Deleted catalog '{catalog_name}', using config file: '{args.config_file}'")
140
140
  logger.debug(f"Stdout: manager.py --remove-specific-archive output:\n{result.stdout}")
@@ -158,8 +158,32 @@ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW,
158
158
  See section 15 and section 16 in the supplied "LICENSE" file.''')
159
159
 
160
160
 
161
+ def confirm_full_archive_deletion(archive_name: str, test_mode=False) -> bool:
162
+ try:
163
+ if test_mode:
164
+ confirmation = os.environ.get("CLEANUP_TEST_DELETE_FULL")
165
+ if confirmation is None:
166
+ raise RuntimeError("envvar 'CLEANUP_TEST_DELETE_FULL' not set")
167
+ print(f"Simulated confirmation for FULL archive: {confirmation}")
168
+ else:
169
+ confirmation = inputimeout(
170
+ prompt=f"Are you sure you want to delete the FULL archive '{archive_name}'? (yes/no): ",
171
+ timeout=30)
172
+ if confirmation is None:
173
+ logger.info(f"No confirmation received for FULL archive: {archive_name}. Skipping deletion.")
174
+ return False
175
+ return confirmation.strip().lower() == "yes"
176
+ except TimeoutOccurred:
177
+ logger.info(f"Timeout waiting for confirmation for FULL archive: {archive_name}. Skipping deletion.")
178
+ return False
179
+ except KeyboardInterrupt:
180
+ logger.info(f"User interrupted confirmation for FULL archive: {archive_name}. Skipping deletion.")
181
+ return False
182
+
183
+
184
+
161
185
  def main():
162
- global logger
186
+ global logger, runner
163
187
 
164
188
  parser = argparse.ArgumentParser(description="Cleanup old archives according to AGE configuration.")
165
189
  parser.add_argument('-d', '--backup-definition', help="Specific backup definition to cleanup.")
@@ -188,7 +212,8 @@ def main():
188
212
  # command_output_log = os.path.join(config_settings.logfile_location.removesuffix("dar-backup.log"), "dar-backup-commands.log")
189
213
  command_output_log = config_settings.logfile_location.replace("dar-backup.log", "dar-backup-commands.log")
190
214
  logger = setup_logging(config_settings.logfile_location, command_output_log, args.log_level, args.log_stdout)
191
-
215
+ command_logger = get_logger(command_output_logger = True)
216
+ runner = CommandRunner(logger=logger, command_logger=command_logger)
192
217
 
193
218
  logger.info(f"=====================================")
194
219
  logger.info(f"cleanup.py started, version: {about.__version__}")
@@ -205,21 +230,9 @@ def main():
205
230
  args.verbose and (print(f"--alternate-archive-dir: {args.alternate_archive_dir}"))
206
231
  args.verbose and (print(f"--cleanup-specific-archives:{args.cleanup_specific_archives}"))
207
232
 
208
- # run PREREQ scripts
209
- if 'PREREQ' in config_settings.config:
210
- for key in sorted(config_settings.config['PREREQ'].keys()):
211
- script = config_settings.config['PREREQ'][key]
212
- try:
213
- result = subprocess.run(script, shell=True, check=True)
214
- logger.info(f"PREREQ {key}: '{script}' run, return code: {result.returncode}")
215
- logger.info(f"PREREQ stdout:\n{result.stdout}")
216
- except subprocess.CalledProcessError as e:
217
- logger.error(f"Error executing {key}: '{script}': {e}")
218
- if result:
219
- logger.error(f"PREREQ stderr:\n{result.stderr}")
220
- print(f"Error executing {script}: {e}")
221
- sys.exit(1)
222
233
 
234
+ # run PREREQ scripts
235
+ requirements('PREREQ', config_settings)
223
236
 
224
237
  if args.alternate_archive_dir:
225
238
  if not os.path.exists(args.alternate_archive_dir):
@@ -230,37 +243,15 @@ def main():
230
243
  sys.exit(1)
231
244
  config_settings.backup_dir = args.alternate_archive_dir
232
245
 
246
+ if args.cleanup_specific_archives is None and args.test_mode:
247
+ logger.info("No --cleanup-specific-archives provided; skipping specific archive deletion in test mode.")
233
248
 
234
249
  if args.cleanup_specific_archives:
235
250
  logger.info(f"Cleaning up specific archives: {args.cleanup_specific_archives}")
236
251
  archive_names = args.cleanup_specific_archives.split(',')
237
252
  for archive_name in archive_names:
238
253
  if "_FULL_" in archive_name:
239
- try:
240
- try:
241
- # used for pytest cases
242
- if args.test_mode:
243
- confirmation = os.environ.get("CLEANUP_TEST_DELETE_FULL")
244
- if confirmation == None:
245
- raise RuntimeError("envvar 'CLEANUP_TEST_DELETE_FULL' not set")
246
-
247
- else:
248
- confirmation = inputimeout(
249
- prompt=f"Are you sure you want to delete the FULL archive '{archive_name}'? (yes/no): ",
250
- timeout=30)
251
- if confirmation == None:
252
- continue
253
- else:
254
- confirmation = confirmation.strip().lower()
255
- except TimeoutOccurred:
256
- logger.info(f"Timeout waiting for confirmation for FULL archive: {archive_name}. Skipping deletion.")
257
- continue
258
- except KeyboardInterrupt:
259
- logger.info(f"User interrupted confirmation for FULL archive: {archive_name}. Skipping deletion.")
260
- continue
261
-
262
- if confirmation != 'yes':
263
- logger.info(f"User did not answer 'yes' to confirm deletion of FULL archive: {archive_name}. Skipping deletion.")
254
+ if not confirm_full_archive_deletion(archive_name, args.test_mode):
264
255
  continue
265
256
  logger.info(f"Deleting archive: {archive_name}")
266
257
  delete_archive(config_settings.backup_dir, archive_name.strip(), args)
@@ -279,19 +270,13 @@ def main():
279
270
  delete_old_backups(config_settings.backup_dir, config_settings.diff_age, 'DIFF', args, definition)
280
271
  delete_old_backups(config_settings.backup_dir, config_settings.incr_age, 'INCR', args, definition)
281
272
 
273
+ # run POST scripts
274
+ requirements('POSTREQ', config_settings)
275
+
282
276
 
283
277
  end_time=int(time())
284
278
  logger.info(f"END TIME: {end_time}")
285
-
286
- # error_lines = extract_error_lines(config_settings.logfile_location, start_time, end_time)
287
- # if len(error_lines) > 0:
288
- # args.verbose and print("\033[1m\033[31mErrors\033[0m encountered")
289
- # for line in error_lines:
290
- # args.verbose and print(line)
291
- # sys.exit(1)
292
- # else:
293
- # args.verbose and print("\033[1m\033[32mSUCCESS\033[0m No errors encountered")
294
- # sys.exit(0)
279
+ sys.exit(0)
295
280
 
296
281
  if __name__ == "__main__":
297
282
  main()
@@ -0,0 +1,81 @@
1
+ import subprocess
2
+ import logging
3
+ import threading
4
+ import os
5
+ import sys
6
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../src")))
7
+ from typing import List, Optional
8
+
9
+ class CommandResult:
10
+ def __init__(self, returncode: int, stdout: str, stderr: str):
11
+ self.returncode = returncode
12
+ self.stdout = stdout
13
+ self.stderr = stderr
14
+
15
+ def __repr__(self):
16
+ return f"<CommandResult returncode={self.returncode}>"
17
+
18
+ class CommandRunner:
19
+ def __init__(
20
+ self,
21
+ logger: Optional[logging.Logger] = None,
22
+ command_logger: Optional[logging.Logger] = None,
23
+ default_timeout: int = 30
24
+ ):
25
+ self.logger = logger or logging.getLogger(__name__)
26
+ self.command_logger = command_logger or self.logger
27
+ self.default_timeout = default_timeout
28
+
29
+ def run(
30
+ self,
31
+ cmd: List[str],
32
+ *,
33
+ timeout: Optional[int] = None,
34
+ check: bool = False,
35
+ capture_output: bool = True,
36
+ text: bool = True
37
+ ) -> CommandResult:
38
+ timeout = timeout or self.default_timeout
39
+ self.logger.debug(f"Executing command: {' '.join(cmd)} (timeout={timeout}s)")
40
+
41
+ process = subprocess.Popen(
42
+ cmd,
43
+ stdout=subprocess.PIPE if capture_output else None,
44
+ stderr=subprocess.PIPE if capture_output else None,
45
+ text=text,
46
+ bufsize=1
47
+ )
48
+
49
+ stdout_lines = []
50
+ stderr_lines = []
51
+
52
+ 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()
57
+
58
+ threads = []
59
+ if capture_output and process.stdout:
60
+ t_out = threading.Thread(target=stream_output, args=(process.stdout, stdout_lines, logging.INFO))
61
+ t_out.start()
62
+ threads.append(t_out)
63
+ if capture_output and process.stderr:
64
+ t_err = threading.Thread(target=stream_output, args=(process.stderr, stderr_lines, logging.ERROR))
65
+ t_err.start()
66
+ threads.append(t_err)
67
+
68
+ try:
69
+ process.wait(timeout=timeout)
70
+ except subprocess.TimeoutExpired:
71
+ process.kill()
72
+ self.logger.error(f"Command timed out: {' '.join(cmd)}")
73
+ return CommandResult(-1, ''.join(stdout_lines), ''.join(stderr_lines))
74
+
75
+ for t in threads:
76
+ t.join()
77
+
78
+ if check and process.returncode != 0:
79
+ self.logger.error(f"Command failed with exit code {process.returncode}")
80
+
81
+ return CommandResult(process.returncode, ''.join(stdout_lines), ''.join(stderr_lines))
@@ -63,7 +63,13 @@ class ConfigSettings:
63
63
  self.diff_age = int(self.config['AGE']['DIFF_AGE'])
64
64
  self.incr_age = int(self.config['AGE']['INCR_AGE'])
65
65
  self.error_correction_percent = int(self.config['PAR2']['ERROR_CORRECTION_PERCENT'])
66
- self.par2_enabled = self.config['PAR2']['ENABLED'].lower() in ('true', '1', 'yes')
66
+ val = self.config['PAR2']['ENABLED'].strip().lower()
67
+ if val in ('true', '1', 'yes'):
68
+ self.par2_enabled = True
69
+ elif val in ('false', '0', 'no'):
70
+ self.par2_enabled = False
71
+ else:
72
+ raise ValueError(f"Invalid boolean value for 'ENABLED' in [PAR2]: '{val}'")
67
73
 
68
74
  # Ensure the directories exist
69
75
  Path(self.backup_dir).mkdir(parents=True, exist_ok=True)
@@ -9,7 +9,7 @@ MIN_SIZE_VERIFICATION_MB = 1
9
9
  NO_FILES_VERIFICATION = 5
10
10
  # timeout in seconds for backup, test, restore and par2 operations
11
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.
12
+ # If a timeout is not specified when using the CommandRunner, a default timeout of 30 secs is used.
13
13
  COMMAND_TIMEOUT_SECS = 86400
14
14
 
15
15
  [DIRECTORIES]
dar_backup/dar_backup.py CHANGED
@@ -1,5 +1,19 @@
1
1
  #!/usr/bin/env python3
2
2
 
3
+ """
4
+ installer.py source code is here: https://github.com/per2jensen/dar-backup/tree/main/v2/src/dar_backup/installer.py
5
+ This script is part of dar-backup, a backup solution for Linux using dar and systemd.
6
+
7
+ Licensed under GNU GENERAL PUBLIC LICENSE v3, see the supplied file "LICENSE" for details.
8
+
9
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW,
10
+ not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
11
+ See section 15 and section 16 in the supplied "LICENSE" file
12
+
13
+ This script can be used to control `dar` to backup parts of or the whole system.
14
+ """
15
+
16
+
3
17
  import argparse
4
18
  import filecmp
5
19
 
@@ -11,6 +25,7 @@ import shutil
11
25
  import subprocess
12
26
  import xml.etree.ElementTree as ET
13
27
  import tempfile
28
+ import threading
14
29
 
15
30
  from argparse import ArgumentParser
16
31
  from datetime import datetime
@@ -20,19 +35,26 @@ from sys import stderr
20
35
  from sys import argv
21
36
  from sys import version_info
22
37
  from time import time
38
+ from threading import Event
23
39
  from typing import List
24
40
 
25
41
  from . import __about__ as about
26
42
  from dar_backup.config_settings import ConfigSettings
27
43
  from dar_backup.util import list_backups
28
- from dar_backup.util import run_command
29
44
  from dar_backup.util import setup_logging
30
45
  from dar_backup.util import get_logger
31
46
  from dar_backup.util import BackupError
32
47
  from dar_backup.util import RestoreError
48
+ from dar_backup.util import requirements
49
+
50
+ from dar_backup.command_runner import CommandRunner
51
+ from dar_backup.command_runner import CommandResult
52
+
53
+ from dar_backup.rich_progress import show_log_driven_bar
33
54
 
34
55
 
35
56
  logger = None
57
+ runner = None
36
58
 
37
59
  def generic_backup(type: str, command: List[str], backup_file: str, backup_definition: str, darrc: str, config_settings: ConfigSettings, args: argparse.Namespace) -> List[str]:
38
60
  """
@@ -63,9 +85,32 @@ def generic_backup(type: str, command: List[str], backup_file: str, backup_defin
63
85
  result: List[tuple] = []
64
86
 
65
87
  logger.info(f"===> Starting {type} backup for {backup_definition}")
66
- logger.info(f"Running command: {' '.join(map(shlex.quote, command))}")
67
88
  try:
68
- process = run_command(command, config_settings.command_timeout_secs)
89
+ log_basename = os.path. dirname(config_settings.logfile_location)
90
+ logfile = os.path.basename(config_settings.logfile_location)[:-4] + "-commands.log"
91
+ log_path = os.path.join( log_basename, logfile)
92
+ logger.debug(f"Commands log file: {log_path}")
93
+
94
+ # wrap a progress bar around the dar command
95
+ stop_event = Event()
96
+ session_marker = f"=== START BACKUP SESSION: {int(time())} ==="
97
+ get_logger(command_output_logger=True).info(session_marker)
98
+
99
+ progress_thread = threading.Thread(
100
+ target=show_log_driven_bar,
101
+ args=(log_path, stop_event, session_marker),
102
+ daemon=True
103
+ )
104
+ progress_thread.start()
105
+ try:
106
+ process = runner.run(command, timeout = config_settings.command_timeout_secs)
107
+ except Exception as e:
108
+ print(f"[!] Backup failed: {e}")
109
+ raise
110
+ finally:
111
+ stop_event.set()
112
+ progress_thread.join()
113
+
69
114
  if process.returncode == 0:
70
115
  logger.info(f"{type} backup completed successfully.")
71
116
  elif process.returncode == 5:
@@ -75,7 +120,7 @@ def generic_backup(type: str, command: List[str], backup_file: str, backup_defin
75
120
 
76
121
  if process.returncode == 0 or process.returncode == 5:
77
122
  add_catalog_command = ['manager', '--add-specific-archive' ,backup_file, '--config-file', args.config_file]
78
- command_result = run_command(add_catalog_command, config_settings.command_timeout_secs)
123
+ command_result = runner.run(add_catalog_command, timeout = config_settings.command_timeout_secs)
79
124
  if command_result.returncode == 0:
80
125
  logger.info(f"Catalog for archive '{backup_file}' added successfully to its manager.")
81
126
  else:
@@ -192,8 +237,33 @@ def verify(args: argparse.Namespace, backup_file: str, backup_definition: str, c
192
237
  """
193
238
  result = True
194
239
  command = ['dar', '-t', backup_file, '-Q']
195
- logger.debug(f"Running command: {' '.join(map(shlex.quote, command))}")
196
- process = run_command(command, config_settings.command_timeout_secs)
240
+
241
+
242
+ log_basename = os.path. dirname(config_settings.logfile_location)
243
+ logfile = os.path.basename(config_settings.logfile_location)[:-4] + "-commands.log"
244
+ log_path = os.path.join( log_basename, logfile)
245
+
246
+ # wrap a progress bar around the dar command
247
+ stop_event = Event()
248
+ session_marker = f"=== START BACKUP SESSION: {int(time())} ==="
249
+ get_logger(command_output_logger=True).info(session_marker)
250
+
251
+ progress_thread = threading.Thread(
252
+ target=show_log_driven_bar,
253
+ args=(log_path, stop_event, session_marker),
254
+ daemon=True
255
+ )
256
+ progress_thread.start()
257
+ try:
258
+ process = runner.run(command, timeout = config_settings.command_timeout_secs)
259
+ except Exception as e:
260
+ print(f"[!] Backup failed: {e}")
261
+ raise
262
+ finally:
263
+ stop_event.set()
264
+ progress_thread.join()
265
+
266
+
197
267
  if process.returncode == 0:
198
268
  logger.info("Archive integrity test passed.")
199
269
  else:
@@ -235,7 +305,7 @@ def verify(args: argparse.Namespace, backup_file: str, backup_definition: str, c
235
305
  args.verbose and logger.info(f"Restoring file: '{restored_file_path}' from backup to: '{config_settings.test_restore_dir}' for file comparing")
236
306
  command = ['dar', '-x', backup_file, '-g', restored_file_path.lstrip("/"), '-R', config_settings.test_restore_dir, '-Q', '-B', args.darrc, 'restore-options']
237
307
  args.verbose and logger.info(f"Running command: {' '.join(map(shlex.quote, command))}")
238
- process = run_command(command, config_settings.command_timeout_secs)
308
+ process = runner.run(command, timeout = config_settings.command_timeout_secs)
239
309
  if process.returncode != 0:
240
310
  raise Exception(str(process))
241
311
 
@@ -276,7 +346,7 @@ def restore_backup(backup_name: str, config_settings: ConfigSettings, restore_di
276
346
  command.extend(selection_criteria)
277
347
  command.extend(['-B', darrc, 'restore-options']) # the .darrc `restore-options` section
278
348
  logger.info(f"Running restore command: {' '.join(map(shlex.quote, command))}")
279
- process = run_command(command, config_settings.command_timeout_secs)
349
+ process = runner.run(command, timeout = config_settings.command_timeout_secs)
280
350
  if process.returncode == 0:
281
351
  logger.info(f"Restore completed successfully to: '{restore_dir}'")
282
352
  else:
@@ -309,7 +379,7 @@ def get_backed_up_files(backup_name: str, backup_dir: str):
309
379
  try:
310
380
  command = ['dar', '-l', backup_path, '-am', '-as', "-Txml" , '-Q']
311
381
  logger.debug(f"Running command: {' '.join(map(shlex.quote, command))}")
312
- command_result = run_command(command)
382
+ command_result = runner.run(command)
313
383
  # Parse the XML data
314
384
  file_paths = find_files_with_paths(command_result.stdout)
315
385
  return file_paths
@@ -339,8 +409,7 @@ def list_contents(backup_name, backup_dir, selection=None):
339
409
  if selection:
340
410
  selection_criteria = shlex.split(selection)
341
411
  command.extend(selection_criteria)
342
- logger.info(f"Running command: {' '.join(map(shlex.quote, command))}")
343
- process = run_command(command)
412
+ process = runner.run(command)
344
413
  stdout,stderr = process.stdout, process.stderr
345
414
  if process.returncode != 0:
346
415
  logger.error(f"Error listing contents of backup: '{backup_name}'")
@@ -402,7 +471,8 @@ def perform_backup(args: argparse.Namespace, config_settings: ConfigSettings, ba
402
471
  if '_' in args.backup_definition:
403
472
  msg = f"Skipping backup definition: '{args.backup_definition}' due to '_' in name"
404
473
  logger.error(msg)
405
- return results.append((msg, 1))
474
+ results.append((msg, 1))
475
+ return results
406
476
  backup_definitions.append((os.path.basename(args.backup_definition).split('.')[0], os.path.join(config_settings.backup_d_dir, args.backup_definition)))
407
477
  else:
408
478
  for root, _, files in os.walk(config_settings.backup_d_dir):
@@ -512,7 +582,7 @@ def generate_par2_files(backup_file: str, config_settings: ConfigSettings, args)
512
582
 
513
583
  # Run the par2 command to generate redundancy files with error correction
514
584
  command = ['par2', 'create', f'-r{config_settings.error_correction_percent}', '-q', '-q', file_path]
515
- process = run_command(command, config_settings.command_timeout_secs)
585
+ process = runner.run(command, timeout = config_settings.command_timeout_secs)
516
586
 
517
587
  if process.returncode == 0:
518
588
  logger.info(f"{counter}/{number_of_slices}: Done")
@@ -620,45 +690,57 @@ INCR back of a single backup definition in backup.d
620
690
 
621
691
 
622
692
 
623
- def requirements(type: str, config_setting: ConfigSettings):
693
+ def print_markdown(source: str, from_string: bool = False, pretty: bool = True):
624
694
  """
625
- Perform PREREQ or POSTREQ requirements.
626
-
695
+ Print Markdown content either from a file or directly from a string.
696
+
627
697
  Args:
628
- type (str): The type of prereq (PREREQ, POSTREQ).
629
- config_settings (ConfigSettings): An instance of the ConfigSettings class.
698
+ source: Path to the file or Markdown string itself.
699
+ from_string: If True, treat `source` as Markdown string instead of file path.
700
+ pretty: If True, render with rich formatting if available.
701
+ """
702
+ import os
703
+ import sys
630
704
 
631
- Raises:
632
- RuntimeError: If a subprocess returns anything but zero.
705
+ content = ""
706
+ if from_string:
707
+ content = source
708
+ else:
709
+ if not os.path.exists(source):
710
+ print(f"❌ File not found: {source}")
711
+ sys.exit(1)
712
+ with open(source, "r", encoding="utf-8") as f:
713
+ content = f.read()
714
+
715
+ if pretty:
716
+ try:
717
+ from rich.console import Console
718
+ from rich.markdown import Markdown
719
+ console = Console()
720
+ console.print(Markdown(content))
721
+ except ImportError:
722
+ print("⚠️ 'rich' not installed. Falling back to plain text.\n")
723
+ print(content)
724
+ else:
725
+ print(content)
726
+
727
+
728
+
729
+ def print_changelog(path: str = None, pretty: bool = True):
730
+ if path is None:
731
+ path = Path(__file__).parent / "Changelog.md"
732
+ print_markdown(str(path), pretty=pretty)
733
+
734
+
735
+ def print_readme(path: str = None, pretty: bool = True):
736
+ if path is None:
737
+ path = Path(__file__).parent / "README.md"
738
+ print_markdown(str(path), pretty=pretty)
633
739
 
634
- subprocess.CalledProcessError: if CalledProcessError is raised in subprocess.run(), let it bobble up.
635
- """
636
- if type is None or config_setting is None:
637
- raise RuntimeError(f"requirements: 'type' or config_setting is None")
638
-
639
- allowed_types = ['PREREQ', 'POSTREQ']
640
- if type not in allowed_types:
641
- raise RuntimeError(f"requirements: {type} not in: {allowed_types}")
642
-
643
-
644
- logger.info(f"Performing {type}")
645
- if type in config_setting.config:
646
- for key in sorted(config_setting.config[type].keys()):
647
- script = config_setting.config[type][key]
648
- try:
649
- result = subprocess.run(script, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, shell=True, check=True)
650
- logger.debug(f"{type} {key}: '{script}' run, return code: {result.returncode}")
651
- logger.debug(f"{type} stdout:\n{result.stdout}")
652
- if result.returncode != 0:
653
- logger.error(f"{type} stderr:\n{result.stderr}")
654
- raise RuntimeError(f"{type} {key}: '{script}' failed, return code: {result.returncode}")
655
- except subprocess.CalledProcessError as e:
656
- logger.error(f"Error executing {key}: '{script}': {e}")
657
- raise e
658
740
 
659
741
 
660
742
  def main():
661
- global logger
743
+ global logger, runner
662
744
  results: List[(str,int)] = [] # a list op tuples (<msg>, <exit code>)
663
745
 
664
746
  MIN_PYTHON_VERSION = (3, 9)
@@ -674,7 +756,6 @@ def main():
674
756
  parser.add_argument('--alternate-reference-archive', help="DIFF or INCR compared to specified archive.")
675
757
  parser.add_argument('-c', '--config-file', type=str, help="Path to 'dar-backup.conf'", default='~/.config/dar-backup/dar-backup.conf')
676
758
  parser.add_argument('--darrc', type=str, help='Optional path to .darrc')
677
- parser.add_argument('--examples', action="store_true", help="Examples of using dar-backup.py.")
678
759
  parser.add_argument('-l', '--list', action='store_true', help="List available archives.")
679
760
  parser.add_argument('--list-contents', help="List the contents of the specified archive.")
680
761
  parser.add_argument('--selection', help="dar file selection for listing/restoring specific files/directories.")
@@ -686,6 +767,11 @@ def main():
686
767
  parser.add_argument('--log-level', type=str, help="`debug` or `trace`", default="info")
687
768
  parser.add_argument('--log-stdout', action='store_true', help='also print log messages to stdout')
688
769
  parser.add_argument('--do-not-compare', action='store_true', help="do not compare restores to file system")
770
+ parser.add_argument('--examples', action="store_true", help="Examples of using dar-backup.py.")
771
+ parser.add_argument("--readme", action="store_true", help="Print README.md to stdout and exit.")
772
+ parser.add_argument("--readme-pretty", action="store_true", help="Print README.md to stdout with Markdown styling and exit.")
773
+ parser.add_argument("--changelog", action="store_true", help="Print Changelog.md to stdout and exit.")
774
+ parser.add_argument("--changelog-pretty", action="store_true", help="Print Changelog.md to stdout with Markdown styling and exit.")
689
775
  parser.add_argument('-v', '--version', action='store_true', help="Show version and license information.")
690
776
  args = parser.parse_args()
691
777
 
@@ -695,6 +781,20 @@ def main():
695
781
  elif args.examples:
696
782
  show_examples()
697
783
  exit(0)
784
+ elif args.readme:
785
+ print_readme(None, pretty=False)
786
+ exit(0)
787
+ elif args.readme_pretty:
788
+ print_readme(None, pretty=True)
789
+ exit(0)
790
+ elif args.changelog:
791
+ print_changelog(None, pretty=False)
792
+ exit(0)
793
+ elif args.changelog_pretty:
794
+ print_changelog(None, pretty=True)
795
+ exit(0)
796
+
797
+
698
798
 
699
799
  if not args.config_file:
700
800
  print(f"Config file not specified, exiting", file=stderr)
@@ -713,6 +813,9 @@ def main():
713
813
  print(f"Error: logfile_location in {args.config_file} does not end at 'dar-backup.log', exiting", file=stderr)
714
814
 
715
815
  logger = setup_logging(config_settings.logfile_location, command_output_log, args.log_level, args.log_stdout)
816
+ command_logger = get_logger(command_output_logger = True)
817
+ runner = CommandRunner(logger=logger, command_logger=command_logger)
818
+
716
819
 
717
820
  try:
718
821
  if not args.darrc:
@@ -741,9 +844,9 @@ def main():
741
844
  file_dir = os.path.normpath(os.path.dirname(__file__))
742
845
  args.verbose and (print(f"Script directory: {file_dir}"))
743
846
  args.verbose and (print(f"Config file: {args.config_file}"))
744
- args.verbose and args.full_backup and (print(f"Type of backup: FULL"))
745
- args.verbose and args.differential_backup and (print(f"Type of backup: DIFF"))
746
- args.verbose and args.incremental_backup and (print(f"Type of backup: INCR"))
847
+ args.verbose and args.full_backup and (print(f"Type of backup: FULL"))
848
+ args.verbose and args.differential_backup and (print(f"Type of backup: DIFF"))
849
+ args.verbose and args.incremental_backup and (print(f"Type of backup: INCR"))
747
850
  args.verbose and args.backup_definition and (print(f"Backup definition: '{args.backup_definition}'"))
748
851
  if args.alternate_reference_archive:
749
852
  args.verbose and (print(f"Alternate ref archive: {args.alternate_reference_archive}"))