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.
@@ -0,0 +1,119 @@
1
+ from pathlib import Path
2
+ import subprocess
3
+ import argparse
4
+
5
+ SERVICE_TEMPLATE = """[Unit]
6
+ Description=dar-backup {mode}
7
+ StartLimitIntervalSec=120
8
+ StartLimitBurst=1
9
+
10
+ [Service]
11
+ Type=oneshot
12
+ TimeoutSec=infinity
13
+ RemainAfterExit=no
14
+ ExecStart=/bin/bash -c '{exec_command}'
15
+ """
16
+
17
+ TIMER_TEMPLATE = """[Unit]
18
+ Description=dar-backup {mode} timer
19
+
20
+ [Timer]
21
+ OnCalendar={calendar}
22
+ Persistent=true
23
+
24
+ [Install]
25
+ WantedBy=timers.target
26
+ """
27
+
28
+ CLEANUP_SERVICE_TEMPLATE = """[Unit]
29
+ Description=cleanup up old DIFF & INCR backups
30
+ StartLimitIntervalSec=120
31
+ StartLimitBurst=1
32
+
33
+ [Service]
34
+ Type=oneshot
35
+ TimeoutSec=60
36
+ RemainAfterExit=no
37
+ ExecStart=/bin/bash -c '{exec_command}'
38
+ """
39
+
40
+ CLEANUP_TIMER = """[Unit]
41
+ Description=dar-cleanup DIFF & INCR timer
42
+
43
+ [Timer]
44
+ OnCalendar=*-*-* 21:07:00
45
+
46
+ [Install]
47
+ WantedBy=timers.target
48
+ """
49
+
50
+ TIMINGS = {
51
+ "FULL": "*-12-30 10:03:00",
52
+ "DIFF": "*-*-01 19:03:00",
53
+ "INCR": "*-*-04/3 19:03:00"
54
+ }
55
+
56
+ FLAGS = {
57
+ "FULL": "-F",
58
+ "DIFF": "-D",
59
+ "INCR": "-I"
60
+ }
61
+
62
+ def build_exec_command(venv, flag, dar_path=None, tool='dar-backup'):
63
+ if dar_path:
64
+ return f"PATH={dar_path}:$PATH && . {venv}/bin/activate && {tool} {flag} --verbose --log-stdout"
65
+ return f". {venv}/bin/activate && {tool} {flag} --verbose --log-stdout"
66
+
67
+ def generate_service(mode, venv, dar_path):
68
+ exec_command = build_exec_command(venv, FLAGS[mode], dar_path)
69
+ return SERVICE_TEMPLATE.format(mode=mode, exec_command=exec_command)
70
+
71
+ def generate_timer(mode):
72
+ return TIMER_TEMPLATE.format(mode=mode, calendar=TIMINGS[mode])
73
+
74
+ def generate_cleanup_service(venv, dar_path):
75
+ exec_command = build_exec_command(venv, "", dar_path, tool='cleanup').strip()
76
+ return CLEANUP_SERVICE_TEMPLATE.format(exec_command=exec_command)
77
+
78
+ def write_unit_file(path, filename, content):
79
+ file_path = path / filename
80
+ file_path.write_text(content)
81
+ print(f"Generated {filename}")
82
+
83
+ def enable_and_start_unit(unit_name):
84
+ subprocess.run(["systemctl", "--user", "enable", unit_name], check=False)
85
+ subprocess.run(["systemctl", "--user", "start", unit_name], check=False)
86
+
87
+ def write_unit_files(venv, dar_path, install=False):
88
+ output_path = Path.home() / ".config/systemd/user" if install else Path.cwd()
89
+ output_path.mkdir(parents=True, exist_ok=True)
90
+
91
+ for mode in FLAGS:
92
+ service_name = f"dar-{mode.lower()}-backup.service"
93
+ timer_name = f"dar-{mode.lower()}-backup.timer"
94
+ write_unit_file(output_path, service_name, generate_service(mode, venv, dar_path))
95
+ write_unit_file(output_path, timer_name, generate_timer(mode))
96
+ print(f" → Fires on: {TIMINGS[mode]}")
97
+
98
+ write_unit_file(output_path, "dar-cleanup.service", generate_cleanup_service(venv, dar_path))
99
+ write_unit_file(output_path, "dar-cleanup.timer", CLEANUP_TIMER)
100
+ print(f" → Fires on: *-*-* 21:07:00")
101
+
102
+ if install:
103
+ for mode in FLAGS:
104
+ enable_and_start_unit(f"dar-{mode.lower()}-backup.timer")
105
+ enable_and_start_unit("dar-cleanup.timer")
106
+ subprocess.run(["systemctl", "--user", "daemon-reexec"], check=False)
107
+ subprocess.run(["systemctl", "--user", "daemon-reload"], check=False)
108
+ print("Systemd `dar-backup` units and timers installed and user daemon reloaded.")
109
+
110
+ def main():
111
+ parser = argparse.ArgumentParser(description="Generate systemd service and timer units for dar-backup.")
112
+ parser.add_argument("--venv", required=True, help="Path to the Python venv with dar-backup")
113
+ parser.add_argument("--dar-path", help="Optional path to dar binary's directory")
114
+ parser.add_argument("--install", action="store_true", help="Install the units to ~/.config/systemd/user")
115
+ args = parser.parse_args()
116
+ write_unit_files(args.venv, args.dar_path, install=args.install)
117
+
118
+ if __name__ == "__main__":
119
+ main()
dar_backup/installer.py CHANGED
@@ -63,6 +63,7 @@ BACKUP_DEFINITION = '''
63
63
  --cache-directory-tagging
64
64
  '''
65
65
 
66
+
66
67
  def main():
67
68
  parser = argparse.ArgumentParser(
68
69
  description="Set up `dar-backup` on your system.",
@@ -72,7 +73,6 @@ def main():
72
73
  action="store_true",
73
74
  help="Deploy a simple config file, use ~/dar-backup/ for log file, archives and restore tests."
74
75
  )
75
-
76
76
  parser.add_argument(
77
77
  "-v", "--version",
78
78
  action="version",
@@ -85,38 +85,54 @@ def main():
85
85
  errors = []
86
86
  if os.path.exists(CONFIG_DIR):
87
87
  errors.append(f"Config directory '{CONFIG_DIR}' already exists.")
88
-
89
88
  if os.path.exists(DAR_BACKUP_DIR):
90
89
  errors.append(f"Directory '{DAR_BACKUP_DIR}' already exists.")
91
90
 
92
- if len(errors) > 0:
91
+ if errors:
93
92
  for error in errors:
94
93
  print(f"Error: {error}")
95
- sys.exit(1)
96
-
97
- os.makedirs(DAR_BACKUP_DIR, exist_ok=False)
98
- os.makedirs(os.path.join(DAR_BACKUP_DIR, "backups"), exist_ok=False)
99
- os.makedirs(os.path.join(DAR_BACKUP_DIR, "restore"), exist_ok=False)
100
- os.makedirs(CONFIG_DIR, exist_ok=False)
101
- os.makedirs(os.path.join(CONFIG_DIR, "backup.d"), exist_ok=False)
102
- print(f"Directories created: `{DAR_BACKUP_DIR}` and `{CONFIG_DIR}`")
103
-
104
- script_dir = Path(__file__).parent
105
- source_file = script_dir / "dar-backup.conf"
106
- destination_file = Path(CONFIG_DIR) / "dar-backup.conf"
107
- shutil.copy(source_file, destination_file)
108
- print(f"Config file deployed to {destination_file}")
109
-
110
-
111
- backup_definition = BACKUP_DEFINITION.replace("@@HOME_DIR@@", os.path.expanduser("~"))
112
- with open(os.path.join(CONFIG_DIR, "backup.d", "default"), "w") as f:
113
- f.write(backup_definition)
114
- print(f"Default backup definition file deployed to {os.path.join(CONFIG_DIR, 'backup.d', 'default')}")
94
+ sys.exit(1)
95
+
96
+ try:
97
+ os.makedirs(DAR_BACKUP_DIR, exist_ok=False)
98
+ os.makedirs(os.path.join(DAR_BACKUP_DIR, "backups"), exist_ok=False)
99
+ os.makedirs(os.path.join(DAR_BACKUP_DIR, "restore"), exist_ok=False)
100
+ os.makedirs(CONFIG_DIR, exist_ok=False)
101
+ os.makedirs(os.path.join(CONFIG_DIR, "backup.d"), exist_ok=False)
102
+ print(f"Directories created: `{DAR_BACKUP_DIR}` and `{CONFIG_DIR}`")
103
+
104
+ script_dir = Path(__file__).parent
105
+ source_file = script_dir / "dar-backup.conf"
106
+ destination_file = Path(CONFIG_DIR) / "dar-backup.conf"
107
+
108
+ try:
109
+ shutil.copy2(source_file, destination_file)
110
+ print(f"Config file deployed to {destination_file}")
111
+ except Exception as e:
112
+ print(f"Error: Could not copy config file: {e}")
113
+ sys.exit(1)
114
+
115
+
116
+ backup_definition = BACKUP_DEFINITION.replace("@@HOME_DIR@@", os.path.expanduser("~"))
117
+
118
+ try:
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
+ except Exception as e:
123
+ print(f"Error: Could not write default backup definition: {e}")
124
+ sys.exit(1)
125
+ except Exception as e:
126
+ print(f"Installation failed: {e}")
127
+ sys.exit(1)
128
+
115
129
  print("1. Now run `manager --create` to create the catalog database.")
116
130
  print("2. Then you can run `dar-backup --full-backup` to create a backup.")
117
131
  print("3. List backups with `dar-backup --list`")
118
132
  print("4. List contents of a backup with `dar-backup --list-contents <backup-name>`")
119
133
 
134
+ sys.exit(0)
135
+
120
136
 
121
137
  if __name__ == "__main__":
122
138
  main()
dar_backup/manager.py CHANGED
@@ -29,9 +29,12 @@ import sys
29
29
 
30
30
  from . import __about__ as about
31
31
  from dar_backup.config_settings import ConfigSettings
32
- from dar_backup.util import run_command
33
32
  from dar_backup.util import setup_logging
34
33
  from dar_backup.util import CommandResult
34
+ from dar_backup.util import get_logger
35
+
36
+ from dar_backup.command_runner import CommandRunner
37
+ from dar_backup.command_runner import CommandResult
35
38
 
36
39
  from datetime import datetime
37
40
  from time import time
@@ -44,6 +47,7 @@ SCRIPTDIRPATH = os.path.dirname(SCRIPTPATH)
44
47
  DB_SUFFIX = ".db"
45
48
 
46
49
  logger = None
50
+ runner = None
47
51
 
48
52
  def show_more_help():
49
53
  help_text = f"""
@@ -66,7 +70,7 @@ def create_db(backup_def: str, config_settings: ConfigSettings):
66
70
  else:
67
71
  logger.info(f'Create catalog database: "{database_path}"')
68
72
  command = ['dar_manager', '--create' , database_path]
69
- process = run_command(command)
73
+ process = runner.run(command)
70
74
  logger.debug(f"return code from 'db created': {process.returncode}")
71
75
  if process.returncode == 0:
72
76
  logger.info(f'Database created: "{database_path}"')
@@ -95,16 +99,19 @@ def list_catalogs(backup_def: str, config_settings: ConfigSettings) -> NamedTupl
95
99
  if not os.path.exists(database_path):
96
100
  error_msg = f'Database not found: "{database_path}"'
97
101
  logger.error(error_msg)
98
- commandResult = CommandResult(
99
- process=None,
100
- stdout='',
101
- stderr=error_msg,
102
- returncode=1,
103
- timeout=1,
104
- command=[])
105
- return commandResult
102
+ return CommandResult(1, '', error_msg)
103
+
104
+ # commandResult = CommandResult(
105
+ # process=None,
106
+ # stdout='',
107
+ # stderr=error_msg,
108
+ # returncode=1,
109
+ # timeout=1,
110
+ # command=[])
111
+
112
+ # return commandResult
106
113
  command = ['dar_manager', '--base', database_path, '--list']
107
- process = run_command(command)
114
+ process = runner.run(command)
108
115
  stdout, stderr = process.stdout, process.stderr
109
116
  if process.returncode != 0:
110
117
  logger.error(f'Error listing catalogs for: "{database_path}"')
@@ -132,7 +139,7 @@ def cat_no_for_name(archive: str, config_settings: ConfigSettings) -> int:
132
139
  for line in process.stdout.splitlines():
133
140
  #print(f"{line_no}: '{line}'")
134
141
  line_no += 1
135
- search = re.search(f".*?(\d+)\s+.*?({archive}).*", line)
142
+ search = re.search(rf".*?(\d+)\s+.*?({archive}).*", line)
136
143
  if search:
137
144
  #print(f"FOUND: archive: {search.group(2)}, catalog #: '{search.group(1)}'")
138
145
  logger.info(f"Found archive: '{archive}', catalog #: '{search.group(1)}'")
@@ -156,7 +163,7 @@ def list_archive_contents(archive: str, config_settings: ConfigSettings) -> int
156
163
  logger.error(f"archive: '{archive}' not found in database: '{database_path}'")
157
164
  return 1
158
165
  command = ['dar_manager', '--base', database_path, '-u', f"{cat_no}"]
159
- process = run_command(command)
166
+ process = runner.run(command)
160
167
  stdout, stderr = process.stdout, process.stderr
161
168
  if process.returncode != 0:
162
169
  logger.error(f'Error listing catalogs for: "{database_path}"')
@@ -178,7 +185,7 @@ def list_catalog_contents(catalog_number: int, backup_def: str, config_settings:
178
185
  logger.error(f'Catalog database not found: "{database_path}"')
179
186
  return 1
180
187
  command = ['dar_manager', '--base', database_path, '-u', f"{catalog_number}"]
181
- process = run_command(command)
188
+ process = runner.run(command)
182
189
  stdout, stderr = process.stdout, process.stderr
183
190
  if process.returncode != 0:
184
191
  logger.error(f'Error listing catalogs for: "{database_path}"')
@@ -199,7 +206,7 @@ def find_file(file, backup_def, config_settings):
199
206
  logger.error(f'Database not found: "{database_path}"')
200
207
  return 1
201
208
  command = ['dar_manager', '--base', database_path, '-f', f"{file}"]
202
- process = run_command(command)
209
+ process = runner.run(command)
203
210
  stdout, stderr = process.stdout, process.stderr
204
211
  if process.returncode != 0:
205
212
  logger.error(f'Error finding file: {file} in: "{database_path}"')
@@ -234,7 +241,7 @@ def add_specific_archive(archive: str, config_settings: ConfigSettings, director
234
241
  logger.info(f'Add "{archive_path}" to catalog: "{database}"')
235
242
 
236
243
  command = ['dar_manager', '--base', database_path, "--add", archive_path, "-Q"]
237
- process = run_command(command)
244
+ process = runner.run(command)
238
245
  stdout, stderr = process.stdout, process.stderr
239
246
 
240
247
  if process.returncode == 0:
@@ -338,7 +345,7 @@ def remove_specific_archive(archive: str, config_settings: ConfigSettings) -> in
338
345
  cat_no:int = cat_no_for_name(archive, config_settings)
339
346
  if cat_no >= 0:
340
347
  command = ['dar_manager', '--base', database_path, "--delete", str(cat_no)]
341
- process: CommandResult = run_command(command)
348
+ process: CommandResult = runner.run(command)
342
349
  logger.info(f"CommandResult: {process}")
343
350
  else:
344
351
  logger.warning(f"archive: '{archive}' not found in it's catalog database: {database_path}")
@@ -353,15 +360,7 @@ def remove_specific_archive(archive: str, config_settings: ConfigSettings) -> in
353
360
  return 1
354
361
 
355
362
 
356
-
357
- def main():
358
- global logger
359
-
360
- MIN_PYTHON_VERSION = (3, 9)
361
- if sys.version_info < MIN_PYTHON_VERSION:
362
- sys.stderr.write(f"Error: This script requires Python {'.'.join(map(str, MIN_PYTHON_VERSION))} or higher.\n")
363
- sys.exit(1)
364
-
363
+ def build_arg_parser():
365
364
  parser = argparse.ArgumentParser(description="Creates/maintains `dar` database catalogs")
366
365
  parser.add_argument('-c', '--config-file', type=str, help="Path to 'dar-backup.conf'", default='~/.config/dar-backup/dar-backup.conf')
367
366
  parser.add_argument('--create-db', action='store_true', help='Create missing databases for all backup definitions')
@@ -380,11 +379,30 @@ def main():
380
379
  parser.add_argument('--more-help', action='store_true', help='Show extended help message')
381
380
  parser.add_argument('--version', action='store_true', help='Show version & license')
382
381
 
382
+ return parser
383
+
384
+
385
+
386
+
387
+ def main():
388
+ global logger, runner
389
+
390
+ MIN_PYTHON_VERSION = (3, 9)
391
+ if sys.version_info < MIN_PYTHON_VERSION:
392
+ sys.stderr.write(f"Error: This script requires Python {'.'.join(map(str, MIN_PYTHON_VERSION))} or higher.\n")
393
+ sys.exit(1)
394
+ return
395
+
396
+ parser = argparse.ArgumentParser(description="Creates/maintains `dar` database catalogs")
397
+ # [parser.add_argument(...) as before...]
398
+
399
+ parser = build_arg_parser()
383
400
  args = parser.parse_args()
384
401
 
385
402
  if args.more_help:
386
403
  show_more_help()
387
404
  sys.exit(0)
405
+ return
388
406
 
389
407
  if args.version:
390
408
  print(f"{SCRIPTNAME} {about.__version__}")
@@ -393,84 +411,93 @@ def main():
393
411
  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW, not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
394
412
  See section 15 and section 16 in the supplied "LICENSE" file.''')
395
413
  sys.exit(0)
414
+ return
396
415
 
397
- # setup logging
398
416
  args.config_file = os.path.expanduser(os.path.expandvars(args.config_file))
399
417
  config_settings = ConfigSettings(args.config_file)
400
418
  if not os.path.dirname(config_settings.logfile_location):
401
419
  print(f"Directory for log file '{config_settings.logfile_location}' does not exist, exiting")
402
- sys.exit(1)
420
+ sys.exit(1)
421
+ return
403
422
 
404
- # command_output_log = os.path.join(config_settings.logfile_location.removesuffix("dar-backup.log"), "dar-backup-commands.log")
405
423
  command_output_log = config_settings.logfile_location.replace("dar-backup.log", "dar-backup-commands.log")
406
424
  logger = setup_logging(config_settings.logfile_location, command_output_log, args.log_level, args.log_stdout)
425
+ command_logger = get_logger(command_output_logger=True)
426
+ runner = CommandRunner(logger=logger, command_logger=command_logger)
407
427
 
408
-
409
- start_time=int(time())
428
+ start_time = int(time())
410
429
  logger.info(f"=====================================")
411
430
  logger.info(f"{SCRIPTNAME} started, version: {about.__version__}")
412
431
  logger.info(f"START TIME: {start_time}")
413
432
  logger.debug(f"`args`:\n{args}")
414
433
  logger.debug(f"`config_settings`:\n{config_settings}")
415
434
 
416
-
417
- # Sanity checks before starting
435
+ # --- Sanity checks ---
418
436
  if args.add_dir and not args.add_dir.strip():
419
437
  logger.error("archive dir not given, exiting")
420
438
  sys.exit(1)
439
+ return
421
440
 
422
- if args.add_specific_archive and not args.add_specific_archive.strip():
441
+ if args.add_specific_archive is not None and not args.add_specific_archive.strip():
423
442
  logger.error("specific archive to add not given, exiting")
424
443
  sys.exit(1)
444
+ return
425
445
 
426
446
  if args.remove_specific_archive and not args.remove_specific_archive.strip():
427
447
  logger.error("specific archive to remove not given, exiting")
428
448
  sys.exit(1)
449
+ return
429
450
 
430
451
  if args.add_specific_archive and args.remove_specific_archive:
431
452
  logger.error("you can't add and remove archives in the same operation, exiting")
432
453
  sys.exit(1)
454
+ return
433
455
 
434
456
  if args.add_dir and args.add_specific_archive:
435
457
  logger.error("you cannot add both a directory and an archive")
436
458
  sys.exit(1)
459
+ return
437
460
 
438
461
  if args.backup_def and not args.backup_def.strip():
439
462
  logger.error(f"No backup definition given to --backup-def")
463
+ sys.exit(1)
464
+ return
440
465
 
441
466
  if args.backup_def:
442
467
  backup_def_path = os.path.join(config_settings.backup_d_dir, args.backup_def)
443
468
  if not os.path.exists(backup_def_path):
444
469
  logger.error(f"Backup definition {args.backup_def} does not exist, exiting")
445
470
  sys.exit(1)
446
-
471
+ return
447
472
 
448
473
  if args.list_archive_contents and not args.list_archive_contents.strip():
449
474
  logger.error(f"--list-archive-contents <param> not given, exiting")
450
475
  sys.exit(1)
451
-
476
+ return
452
477
 
453
478
  if args.list_catalog_contents and not args.backup_def:
454
479
  logger.error(f"--list-catalog-contents requires the --backup-def, exiting")
455
480
  sys.exit(1)
481
+ return
456
482
 
457
483
  if args.find_file and not args.backup_def:
458
484
  logger.error(f"--find-file requires the --backup-def, exiting")
459
485
  sys.exit(1)
460
-
461
-
486
+ return
462
487
 
463
- # Modify config settings based on the arguments
488
+ # --- Modify settings ---
464
489
  if args.alternate_archive_dir:
465
490
  if not os.path.exists(args.alternate_archive_dir):
466
491
  logger.error(f"Alternate archive dir '{args.alternate_archive_dir}' does not exist, exiting")
467
492
  sys.exit(1)
493
+ return
468
494
  config_settings.backup_dir = args.alternate_archive_dir
469
495
 
470
-
496
+ # --- Functional logic ---
471
497
  if args.create_db:
472
498
  if args.backup_def:
473
499
  sys.exit(create_db(args.backup_def, config_settings))
500
+ return
474
501
  else:
475
502
  for root, dirs, files in os.walk(config_settings.backup_d_dir):
476
503
  for file in files:
@@ -479,19 +506,19 @@ See section 15 and section 16 in the supplied "LICENSE" file.''')
479
506
  result = create_db(current_backupdef, config_settings)
480
507
  if result != 0:
481
508
  sys.exit(result)
509
+ return
482
510
 
483
511
  if args.add_specific_archive:
484
512
  sys.exit(add_specific_archive(args.add_specific_archive, config_settings))
513
+ return
485
514
 
486
515
  if args.add_dir:
487
516
  sys.exit(add_directory(args, config_settings))
488
-
517
+ return
489
518
 
490
519
  if args.remove_specific_archive:
491
520
  return remove_specific_archive(args.remove_specific_archive, config_settings)
492
521
 
493
-
494
-
495
522
  if args.list_catalogs:
496
523
  if args.backup_def:
497
524
  process = list_catalogs(args.backup_def, config_settings)
@@ -504,19 +531,22 @@ See section 15 and section 16 in the supplied "LICENSE" file.''')
504
531
  if list_catalogs(current_backupdef, config_settings).returncode != 0:
505
532
  result = 1
506
533
  sys.exit(result)
507
-
534
+ return
508
535
 
509
536
  if args.list_archive_contents:
510
537
  result = list_archive_contents(args.list_archive_contents, config_settings)
511
538
  sys.exit(result)
539
+ return
512
540
 
513
541
  if args.list_catalog_contents:
514
542
  result = list_catalog_contents(args.list_catalog_contents, args.backup_def, config_settings)
515
543
  sys.exit(result)
544
+ return
516
545
 
517
546
  if args.find_file:
518
547
  result = find_file(args.find_file, args.backup_def, config_settings)
519
548
  sys.exit(result)
549
+ return
520
550
 
521
551
 
522
552
  if __name__ == "__main__":
@@ -0,0 +1,101 @@
1
+ import os
2
+ import time
3
+ from threading import Event
4
+ from rich.console import Console, Group
5
+ from rich.live import Live
6
+ from rich.text import Text
7
+
8
+ def is_terminal():
9
+ return Console().is_terminal
10
+
11
+ def tail_log_file(log_path, stop_event, session_marker=None):
12
+ """Yields new lines from the log file, starting only after the session_marker is found."""
13
+ last_size = 0
14
+ marker_found = session_marker is None
15
+
16
+ while not stop_event.is_set():
17
+ if not os.path.exists(log_path):
18
+ time.sleep(0.5)
19
+ continue
20
+
21
+ try:
22
+ with open(log_path, "r") as f:
23
+ if last_size > os.path.getsize(log_path):
24
+ f.seek(0)
25
+ else:
26
+ f.seek(last_size)
27
+
28
+ while not stop_event.is_set():
29
+ line = f.readline()
30
+ if not line:
31
+ break
32
+
33
+ line = line.strip()
34
+ last_size = f.tell()
35
+
36
+ if not marker_found:
37
+ if session_marker in line:
38
+ marker_found = True
39
+ continue
40
+
41
+ yield line
42
+
43
+ except Exception as e:
44
+ print(f"[!] Error reading log: {e}")
45
+
46
+ time.sleep(0.5)
47
+
48
+ def get_green_shade(step, max_width):
49
+ """Returns a green color from light to dark across the bar."""
50
+ start = 180
51
+ end = 20
52
+ value = int(start - ((start - end) * (step / max_width)))
53
+ return f"rgb(0,{value},0)"
54
+
55
+ def show_log_driven_bar(log_path: str, stop_event: Event, session_marker: str, max_width=50):
56
+ console = Console()
57
+
58
+ if not console.is_terminal:
59
+ console.log("[~] Not a terminal — progress bar skipped.")
60
+ return
61
+
62
+ progress = 0
63
+ dir_count = 0
64
+ last_dir = "Waiting for directory..."
65
+
66
+
67
+
68
+ with Live(console=console, refresh_per_second=5) as live:
69
+ for line in tail_log_file(log_path, stop_event, session_marker):
70
+ lowered = line.lower()
71
+
72
+ updated = False
73
+
74
+ # Update directory name on "Inspecting"
75
+ if "inspecting directory" in lowered and "finished" not in lowered:
76
+ last_dir = line.split("Inspecting directory")[-1].strip()
77
+ updated = True
78
+
79
+ # Advance progress on "Finished"
80
+ if "finished inspecting directory" in lowered:
81
+ dir_count += 1
82
+ progress = (progress + 1) % (max_width + 1)
83
+ updated = True
84
+
85
+ if updated:
86
+ bar_text = ""
87
+ for i in range(max_width):
88
+ if i < progress:
89
+ color = get_green_shade(i, max_width)
90
+ bar_text += f"[{color}]#[/{color}]"
91
+ else:
92
+ bar_text += "-"
93
+
94
+ bar = Text.from_markup(f"[white][{bar_text}][/white] [dim]Dirs: {dir_count}[/dim]")
95
+ dir_display = Text(f"📂 {last_dir}", style="dim")
96
+
97
+ live.update(Group(bar, dir_display))
98
+
99
+ if stop_event.is_set():
100
+ break
101
+