dar-backup 1.0.1__py3-none-any.whl → 1.1.0__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.
@@ -13,6 +13,7 @@ try:
13
13
  except ImportError:
14
14
  termios = None
15
15
  import tempfile
16
+ import time
16
17
  sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../src")))
17
18
  from typing import List, Optional, Union
18
19
  from dar_backup.util import get_logger
@@ -84,15 +85,26 @@ class CommandRunner:
84
85
  self,
85
86
  logger: Optional[logging.Logger] = None,
86
87
  command_logger: Optional[logging.Logger] = None,
87
- default_timeout: int = 30
88
+ default_timeout: int = 30,
89
+ default_capture_limit_bytes: Optional[int] = None
88
90
  ):
89
91
  self.logger = logger or get_logger()
90
92
  self.command_logger = command_logger or get_logger(command_output_logger=True)
93
+ if default_timeout is not None:
94
+ try:
95
+ default_timeout = int(default_timeout)
96
+ except (TypeError, ValueError):
97
+ default_timeout = 30
98
+ if not isinstance(default_timeout, int):
99
+ default_timeout = 30
91
100
  self.default_timeout = default_timeout
101
+ self.default_capture_limit_bytes = default_capture_limit_bytes
92
102
 
93
103
  if not self.logger or not self.command_logger:
94
104
  self.logger_fallback()
95
105
 
106
+ if self.default_timeout is not None and self.default_timeout <= 0:
107
+ self.default_timeout = None
96
108
 
97
109
  def logger_fallback(self):
98
110
  """
@@ -129,12 +141,21 @@ class CommandRunner:
129
141
  timeout: Optional[int] = None,
130
142
  check: bool = False,
131
143
  capture_output: bool = True,
144
+ capture_output_limit_bytes: Optional[int] = None,
145
+ log_output: bool = True,
132
146
  text: bool = True,
133
147
  cwd: Optional[str] = None,
134
148
  stdin: Optional[int] = subprocess.DEVNULL
135
149
  ) -> CommandResult:
136
150
  self._text_mode = text
137
- timeout = timeout or self.default_timeout
151
+ if timeout is None:
152
+ timeout = self.default_timeout
153
+ if timeout is not None and timeout <= 0:
154
+ timeout = None
155
+ if capture_output_limit_bytes is None:
156
+ capture_output_limit_bytes = self.default_capture_limit_bytes
157
+ if capture_output_limit_bytes is not None and capture_output_limit_bytes < 0:
158
+ capture_output_limit_bytes = None
138
159
 
139
160
  tty_fd = None
140
161
  tty_file = None
@@ -163,9 +184,13 @@ class CommandRunner:
163
184
  except ValueError as e:
164
185
  stack = traceback.format_exc()
165
186
  self.logger.error(f"Command sanitation failed: {e}")
187
+ if isinstance(cmd, list):
188
+ cmd_text = " ".join(map(str, cmd))
189
+ else:
190
+ cmd_text = str(cmd)
166
191
  return CommandResult(
167
192
  returncode=-1,
168
- note=f"Sanitizing failed: command: {' '.join(cmd)}",
193
+ note=f"Sanitizing failed: command: {cmd_text}",
169
194
  stdout='',
170
195
  stderr=str(e),
171
196
  stack=stack,
@@ -183,17 +208,33 @@ class CommandRunner:
183
208
 
184
209
  stdout_lines = []
185
210
  stderr_lines = []
211
+ truncated_stdout = {"value": False}
212
+ truncated_stderr = {"value": False}
186
213
 
187
214
  try:
215
+ start_time = time.monotonic()
216
+ use_pipes = capture_output or log_output
188
217
  process = subprocess.Popen(
189
218
  cmd,
190
- stdout=subprocess.PIPE if capture_output else None,
191
- stderr=subprocess.PIPE if capture_output else None,
219
+ stdout=subprocess.PIPE if use_pipes else None,
220
+ stderr=subprocess.PIPE if use_pipes else None,
192
221
  stdin=stdin,
193
222
  text=False,
194
223
  bufsize=-1,
195
224
  cwd=cwd
196
225
  )
226
+ pid = getattr(process, "pid", None)
227
+ if log_output:
228
+ self.command_logger.debug(
229
+ "Process started pid=%s cwd=%s",
230
+ pid if pid is not None else "unknown",
231
+ cwd or os.getcwd(),
232
+ )
233
+ self.logger.debug(
234
+ "Process started pid=%s cwd=%s",
235
+ pid if pid is not None else "unknown",
236
+ cwd or os.getcwd(),
237
+ )
197
238
  except Exception as e:
198
239
  stack = traceback.format_exc()
199
240
  return CommandResult(
@@ -203,7 +244,8 @@ class CommandRunner:
203
244
  stack=stack
204
245
  )
205
246
 
206
- def stream_output(stream, lines, level):
247
+ def stream_output(stream, lines, level, truncated_flag):
248
+ captured_bytes = 0
207
249
  try:
208
250
  while True:
209
251
  chunk = stream.read(1024)
@@ -211,10 +253,40 @@ class CommandRunner:
211
253
  break
212
254
  if self._text_mode:
213
255
  decoded = chunk.decode('utf-8', errors='replace')
214
- lines.append(decoded)
215
- self.command_logger.log(level, decoded.strip())
256
+ if log_output:
257
+ self.command_logger.log(level, decoded.strip())
258
+ if capture_output:
259
+ if capture_output_limit_bytes is None:
260
+ lines.append(decoded)
261
+ else:
262
+ remaining = capture_output_limit_bytes - captured_bytes
263
+ if remaining > 0:
264
+ if len(chunk) <= remaining:
265
+ lines.append(decoded)
266
+ captured_bytes += len(chunk)
267
+ else:
268
+ piece = chunk[:remaining]
269
+ lines.append(piece.decode('utf-8', errors='replace'))
270
+ captured_bytes = capture_output_limit_bytes
271
+ truncated_flag["value"] = True
272
+ else:
273
+ truncated_flag["value"] = True
216
274
  else:
217
- lines.append(chunk)
275
+ if capture_output:
276
+ if capture_output_limit_bytes is None:
277
+ lines.append(chunk)
278
+ else:
279
+ remaining = capture_output_limit_bytes - captured_bytes
280
+ if remaining > 0:
281
+ if len(chunk) <= remaining:
282
+ lines.append(chunk)
283
+ captured_bytes += len(chunk)
284
+ else:
285
+ lines.append(chunk[:remaining])
286
+ captured_bytes = capture_output_limit_bytes
287
+ truncated_flag["value"] = True
288
+ else:
289
+ truncated_flag["value"] = True
218
290
  # Avoid logging raw binary data to prevent garbled logs
219
291
  except Exception as e:
220
292
  self.logger.warning(f"stream_output decode error: {e}")
@@ -222,12 +294,18 @@ class CommandRunner:
222
294
  stream.close()
223
295
 
224
296
  threads = []
225
- if capture_output and process.stdout:
226
- t_out = threading.Thread(target=stream_output, args=(process.stdout, stdout_lines, logging.INFO))
297
+ if (capture_output or log_output) and process.stdout:
298
+ t_out = threading.Thread(
299
+ target=stream_output,
300
+ args=(process.stdout, stdout_lines, logging.INFO, truncated_stdout)
301
+ )
227
302
  t_out.start()
228
303
  threads.append(t_out)
229
- if capture_output and process.stderr:
230
- t_err = threading.Thread(target=stream_output, args=(process.stderr, stderr_lines, logging.ERROR))
304
+ if (capture_output or log_output) and process.stderr:
305
+ t_err = threading.Thread(
306
+ target=stream_output,
307
+ args=(process.stderr, stderr_lines, logging.ERROR, truncated_stderr)
308
+ )
231
309
  t_err.start()
232
310
  threads.append(t_err)
233
311
 
@@ -235,7 +313,12 @@ class CommandRunner:
235
313
  process.wait(timeout=timeout)
236
314
  except subprocess.TimeoutExpired:
237
315
  process.kill()
238
- log_msg = f"Command timed out after {timeout} seconds: {' '.join(cmd)}:\n"
316
+ duration = time.monotonic() - start_time
317
+ pid = getattr(process, "pid", None)
318
+ log_msg = (
319
+ f"Command timed out after {timeout} seconds: {' '.join(cmd)} "
320
+ f"(pid={pid if pid is not None else 'unknown'}, elapsed={duration:.2f}s):\n"
321
+ )
239
322
  self.logger.error(log_msg)
240
323
  return CommandResult(-1, ''.join(stdout_lines), log_msg.join(stderr_lines))
241
324
  except Exception as e:
@@ -246,6 +329,21 @@ class CommandRunner:
246
329
 
247
330
  for t in threads:
248
331
  t.join()
332
+ duration = time.monotonic() - start_time
333
+ pid = getattr(process, "pid", None)
334
+ if log_output:
335
+ self.command_logger.debug(
336
+ "Process finished pid=%s returncode=%s elapsed=%.2fs",
337
+ pid if pid is not None else "unknown",
338
+ process.returncode,
339
+ duration,
340
+ )
341
+ self.logger.debug(
342
+ "Process finished pid=%s returncode=%s elapsed=%.2fs",
343
+ pid if pid is not None else "unknown",
344
+ process.returncode,
345
+ duration,
346
+ )
249
347
 
250
348
  if self._text_mode:
251
349
  stdout_combined = ''.join(stdout_lines)
@@ -254,6 +352,15 @@ class CommandRunner:
254
352
  stdout_combined = b''.join(stdout_lines)
255
353
  stderr_combined = b''.join(stderr_lines)
256
354
 
355
+ note = None
356
+ if truncated_stdout["value"] or truncated_stderr["value"]:
357
+ parts = []
358
+ if truncated_stdout["value"]:
359
+ parts.append("stdout truncated")
360
+ if truncated_stderr["value"]:
361
+ parts.append("stderr truncated")
362
+ note = ", ".join(parts)
363
+
257
364
  if check and process.returncode != 0:
258
365
  self.logger.error(f"Command failed with exit code {process.returncode}")
259
366
  return CommandResult(
@@ -266,7 +373,8 @@ class CommandRunner:
266
373
  return CommandResult(
267
374
  process.returncode,
268
375
  stdout_combined,
269
- stderr_combined
376
+ stderr_combined,
377
+ note=note
270
378
  )
271
379
  finally:
272
380
  if termios is not None and saved_tty_attrs is not None and tty_fd is not None:
@@ -4,7 +4,6 @@ import configparser
4
4
  import re
5
5
  from dataclasses import dataclass, field, fields
6
6
  from os.path import expandvars, expanduser
7
- from pathlib import Path
8
7
  from typing import Optional, Pattern
9
8
 
10
9
  from dar_backup.exceptions import ConfigSettingsError
@@ -39,6 +38,7 @@ class ConfigSettings:
39
38
  min_size_verification_mb: int = field(init=False)
40
39
  no_files_verification: int = field(init=False)
41
40
  command_timeout_secs: int = field(init=False)
41
+ command_capture_max_bytes: Optional[int] = field(init=False, default=None)
42
42
  backup_dir: str = field(init=False)
43
43
  test_restore_dir: str = field(init=False)
44
44
  backup_d_dir: str = field(init=False)
@@ -47,14 +47,14 @@ class ConfigSettings:
47
47
  error_correction_percent: int = field(init=False)
48
48
  par2_enabled: bool = field(init=False)
49
49
  par2_dir: Optional[str] = field(init=False, default=None)
50
- par2_layout: Optional[str] = field(init=False, default=None)
51
- par2_mode: Optional[str] = field(init=False, default=None)
52
50
  par2_ratio_full: Optional[int] = field(init=False, default=None)
53
51
  par2_ratio_diff: Optional[int] = field(init=False, default=None)
54
52
  par2_ratio_incr: Optional[int] = field(init=False, default=None)
55
53
  par2_run_verify: Optional[bool] = field(init=False, default=None)
56
54
  logfile_max_bytes: int = field(init=False)
57
55
  logfile_no_count: int = field(init=False)
56
+ trace_log_max_bytes: int = field(init=False)
57
+ trace_log_backup_count: int = field(init=False)
58
58
  dar_backup_discord_webhook_url: Optional[str] = field(init=False, default=None)
59
59
  restoretest_exclude_prefixes: list[str] = field(init=False, default_factory=list)
60
60
  restoretest_exclude_suffixes: list[str] = field(init=False, default_factory=list)
@@ -83,6 +83,20 @@ class ConfigSettings:
83
83
  "type": int,
84
84
  "default": 5,
85
85
  },
86
+ {
87
+ "section": "MISC",
88
+ "key": "TRACE_LOG_MAX_BYTES",
89
+ "attr": "trace_log_max_bytes",
90
+ "type": int,
91
+ "default": 10485760, # 10 MB
92
+ },
93
+ {
94
+ "section": "MISC",
95
+ "key": "TRACE_LOG_BACKUP_COUNT",
96
+ "attr": "trace_log_backup_count",
97
+ "type": int,
98
+ "default": 1,
99
+ },
86
100
  {
87
101
  "section": "MISC",
88
102
  "key": "DAR_BACKUP_DISCORD_WEBHOOK_URL",
@@ -90,6 +104,13 @@ class ConfigSettings:
90
104
  "type": str,
91
105
  "default": None,
92
106
  },
107
+ {
108
+ "section": "MISC",
109
+ "key": "COMMAND_CAPTURE_MAX_BYTES",
110
+ "attr": "command_capture_max_bytes",
111
+ "type": int,
112
+ "default": 102400,
113
+ },
93
114
  # Add more optional fields here
94
115
  ]
95
116
 
@@ -98,7 +119,7 @@ class ConfigSettings:
98
119
  raise ConfigSettingsError("`config_file` must be specified.")
99
120
 
100
121
  try:
101
- self.config = configparser.ConfigParser()
122
+ self.config = configparser.ConfigParser(inline_comment_prefixes=['#'])
102
123
  loaded_files = self.config.read(self.config_file)
103
124
  if not loaded_files:
104
125
  raise RuntimeError(f"Configuration file not found or unreadable: '{self.config_file}'")
@@ -124,8 +145,6 @@ class ConfigSettings:
124
145
  raise ConfigSettingsError(f"Invalid boolean value for 'ENABLED' in [PAR2]: '{val}'")
125
146
 
126
147
  self.par2_dir = self._get_optional_str("PAR2", "PAR2_DIR", default=None)
127
- self.par2_layout = self._get_optional_str("PAR2", "PAR2_LAYOUT", default="by-backup")
128
- self.par2_mode = self._get_optional_str("PAR2", "PAR2_MODE", default=None)
129
148
  self.par2_ratio_full = self._get_optional_int("PAR2", "PAR2_RATIO_FULL", default=None)
130
149
  self.par2_ratio_diff = self._get_optional_int("PAR2", "PAR2_RATIO_DIFF", default=None)
131
150
  self.par2_ratio_incr = self._get_optional_int("PAR2", "PAR2_RATIO_INCR", default=None)
@@ -240,8 +259,6 @@ class ConfigSettings:
240
259
  """
241
260
  par2_config = {
242
261
  "par2_dir": self.par2_dir,
243
- "par2_layout": self.par2_layout,
244
- "par2_mode": self.par2_mode,
245
262
  "par2_ratio_full": self.par2_ratio_full,
246
263
  "par2_ratio_diff": self.par2_ratio_diff,
247
264
  "par2_ratio_incr": self.par2_ratio_incr,
@@ -260,10 +277,6 @@ class ConfigSettings:
260
277
  continue
261
278
  if key == "PAR2_DIR":
262
279
  par2_config["par2_dir"] = value
263
- elif key == "PAR2_LAYOUT":
264
- par2_config["par2_layout"] = value
265
- elif key == "PAR2_MODE":
266
- par2_config["par2_mode"] = value
267
280
  elif key == "PAR2_RATIO_FULL":
268
281
  par2_config["par2_ratio_full"] = int(value)
269
282
  elif key == "PAR2_RATIO_DIFF":
@@ -17,7 +17,14 @@ NO_FILES_VERIFICATION = 5
17
17
  # The author has such `dar` tasks running for 10-15 hours on the yearly backups, so a value of 24 hours is used.
18
18
  # If a timeout is not specified when using the CommandRunner, a default timeout of 30 secs is used.
19
19
  COMMAND_TIMEOUT_SECS = 86400
20
+ # Optional limit on captured command output (in bytes). Output beyond this
21
+ # size is still logged but not kept in memory. Use 0 to avoid buffering entirely.
22
+ # Default is 102400.
23
+ # COMMAND_CAPTURE_MAX_BYTES = 102400
20
24
  #DAR_BACKUP_DISCORD_WEBHOOK_URL = https://discord.com/api/webhooks/<id>/<token>
25
+ # Optional Trace log configuration (debug level logs with stack traces)
26
+ # TRACE_LOG_MAX_BYTES = 10485760 # 10 MB default
27
+ # TRACE_LOG_BACKUP_COUNT = 1 # 1 backup file default
21
28
 
22
29
  [DIRECTORIES]
23
30
  BACKUP_DIR = @@BACKUP_DIR@@
@@ -31,6 +31,9 @@ LOGFILE_LOCATION = {{ vars_map.DAR_BACKUP_DIR -}}/dar-backup.log
31
31
  # LOGFILE_BACKUP_COUNT = 5 # default, change as needed
32
32
  # DAR_BACKUP_DISCORD_WEBHOOK_URL **should really** be given as an environment variable for security reasons
33
33
  # DAR_BACKUP_DISCORD_WEBHOOK_URL = https://discord.com/api/webhooks/<id>/<token>
34
+ # Optional Trace log configuration (debug level logs with stack traces)
35
+ # TRACE_LOG_MAX_BYTES = 10485760 # 10 MB default
36
+ # TRACE_LOG_BACKUP_COUNT = 1 # 1 backup file default
34
37
 
35
38
  MAX_SIZE_VERIFICATION_MB = 2
36
39
  MIN_SIZE_VERIFICATION_MB = 0
@@ -64,7 +67,6 @@ ERROR_CORRECTION_PERCENT = 5
64
67
  ENABLED = True
65
68
  # Optional PAR2 configuration
66
69
  # PAR2_DIR = /path/to/par2-store
67
- # PAR2_LAYOUT = by-backup
68
70
  # PAR2_RATIOs are meuasured as percentages. Same function as ERROR_CORRECTION_PERCENT
69
71
  # PAR2_RATIO_FULL = 10
70
72
  # PAR2_RATIO_DIFF = 5