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.
- dar_backup/__about__.py +1 -2
- dar_backup/clean_log.py +102 -63
- dar_backup/cleanup.py +139 -107
- dar_backup/command_runner.py +123 -15
- dar_backup/config_settings.py +25 -12
- dar_backup/dar-backup.conf +7 -0
- dar_backup/dar-backup.conf.j2 +3 -1
- dar_backup/dar_backup.py +529 -102
- dar_backup/dar_backup_systemd.py +1 -1
- dar_backup/demo.py +19 -11
- dar_backup/installer.py +18 -1
- dar_backup/manager.py +1085 -96
- dar_backup/util.py +128 -19
- {dar_backup-1.0.1.dist-info → dar_backup-1.1.0.dist-info}/METADATA +320 -42
- dar_backup-1.1.0.dist-info/RECORD +23 -0
- dar_backup/Changelog.md +0 -401
- dar_backup/README.md +0 -2045
- dar_backup-1.0.1.dist-info/RECORD +0 -25
- {dar_backup-1.0.1.dist-info → dar_backup-1.1.0.dist-info}/WHEEL +0 -0
- {dar_backup-1.0.1.dist-info → dar_backup-1.1.0.dist-info}/entry_points.txt +0 -0
- {dar_backup-1.0.1.dist-info → dar_backup-1.1.0.dist-info}/licenses/LICENSE +0 -0
dar_backup/command_runner.py
CHANGED
|
@@ -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
|
-
|
|
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: {
|
|
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
|
|
191
|
-
stderr=subprocess.PIPE if
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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:
|
dar_backup/config_settings.py
CHANGED
|
@@ -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":
|
dar_backup/dar-backup.conf
CHANGED
|
@@ -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@@
|
dar_backup/dar-backup.conf.j2
CHANGED
|
@@ -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
|