summaryrefslogtreecommitdiff
path: root/modules/zfssnap
diff options
context:
space:
mode:
authorGregor Kleen <gkleen@yggdrasil.li>2022-11-01 21:05:33 +0100
committerGregor Kleen <gkleen@yggdrasil.li>2022-11-01 21:05:33 +0100
commita73133a7c629c76a7a328f0e8d2bb693c46ef45d (patch)
tree11112e70dd44a3a1eb7fd1702c0b80baa578b386 /modules/zfssnap
parent4188923d715bb4cfc3542eb05781fd45df85522e (diff)
downloadnixos-a73133a7c629c76a7a328f0e8d2bb693c46ef45d.tar
nixos-a73133a7c629c76a7a328f0e8d2bb693c46ef45d.tar.gz
nixos-a73133a7c629c76a7a328f0e8d2bb693c46ef45d.tar.bz2
nixos-a73133a7c629c76a7a328f0e8d2bb693c46ef45d.tar.xz
nixos-a73133a7c629c76a7a328f0e8d2bb693c46ef45d.zip
fix backups
Diffstat (limited to 'modules/zfssnap')
-rw-r--r--modules/zfssnap/default.nix42
-rw-r--r--modules/zfssnap/zfssnap/setup.py10
-rw-r--r--modules/zfssnap/zfssnap/zfssnap/__main__.py (renamed from modules/zfssnap/zfssnap.py)130
3 files changed, 107 insertions, 75 deletions
diff --git a/modules/zfssnap/default.nix b/modules/zfssnap/default.nix
index 42cdf46f..735e73ec 100644
--- a/modules/zfssnap/default.nix
+++ b/modules/zfssnap/default.nix
@@ -1,32 +1,20 @@
1{ config, pkgs, lib, ... }: 1{ config, pkgs, lib, flakeInputs, ... }:
2 2
3with lib; 3with lib;
4 4
5let 5let
6 zfssnap = pkgs.stdenv.mkDerivation rec { 6 zfssnap = flakeInputs.mach-nix.lib.${config.nixpkgs.system}.buildPythonPackage rec {
7 name = "zfssnap"; 7 pname = "zfssnap";
8 src = ./zfssnap.py; 8 src = ./zfssnap;
9 version = "0.0.0";
10 ignoreDataOutdated = true;
9 11
10 phases = [ "buildPhase" "checkPhase" "installPhase" ]; 12 requirements = ''
11 13 pyxdg
12 buildInputs = with pkgs; [makeWrapper]; 14 pytimeparse
13 15 python-dateutil
14 python = pkgs.python39.withPackages (ps: with ps; [pyxdg pytimeparse python-dateutil]);
15
16 buildPhase = ''
17 substitute $src zfssnap \
18 --subst-var-by python ${escapeShellArg python}
19 '';
20
21 doCheck = true;
22 checkPhase = ''
23 ${python}/bin/python -m py_compile zfssnap
24 ''; 16 '';
25 17 postInstall = ''
26 installPhase = ''
27 install -m 0755 -D -t $out/bin \
28 zfssnap
29
30 wrapProgram $out/bin/zfssnap \ 18 wrapProgram $out/bin/zfssnap \
31 --prefix PATH : ${makeBinPath [config.boot.zfs.package]} 19 --prefix PATH : ${makeBinPath [config.boot.zfs.package]}
32 ''; 20 '';
@@ -71,7 +59,9 @@ in {
71 before = [ "zfssnap-prune.service" ]; 59 before = [ "zfssnap-prune.service" ];
72 serviceConfig = { 60 serviceConfig = {
73 Type = "oneshot"; 61 Type = "oneshot";
74 ExecStart = "${zfssnap}/bin/zfssnap -v"; 62 ExecStart = "${zfssnap}/bin/zfssnap -vv";
63
64 LogRateLimitIntervalSec = 0;
75 }; 65 };
76 }; 66 };
77 systemd.services."zfssnap-prune" = { 67 systemd.services."zfssnap-prune" = {
@@ -82,7 +72,9 @@ in {
82 ExecStart = let 72 ExecStart = let
83 mkSectionName = name: strings.escape [ "[" "]" ] (strings.toUpper name); 73 mkSectionName = name: strings.escape [ "[" "]" ] (strings.toUpper name);
84 zfssnapConfig = generators.toINI { inherit mkSectionName; } cfg.config; 74 zfssnapConfig = generators.toINI { inherit mkSectionName; } cfg.config;
85 in "${zfssnap}/bin/zfssnap -vv prune --config=${pkgs.writeText "zfssnap.ini" zfssnapConfig}"; 75 in "${zfssnap}/bin/zfssnap -vv prune --exec-newest --config=${pkgs.writeText "zfssnap.ini" zfssnapConfig}"; # DEBUG
76
77 LogRateLimitIntervalSec = 0;
86 }; 78 };
87 }; 79 };
88 80
diff --git a/modules/zfssnap/zfssnap/setup.py b/modules/zfssnap/zfssnap/setup.py
new file mode 100644
index 00000000..6c58757d
--- /dev/null
+++ b/modules/zfssnap/zfssnap/setup.py
@@ -0,0 +1,10 @@
1from setuptools import setup
2
3setup(name='zfssnap',
4 packages=['zfssnap'],
5 entry_points={
6 'console_scripts': [
7 'zfssnap=zfssnap.__main__:main',
8 ],
9 }
10)
diff --git a/modules/zfssnap/zfssnap.py b/modules/zfssnap/zfssnap/zfssnap/__main__.py
index a8dae75f..a0eade78 100644
--- a/modules/zfssnap/zfssnap.py
+++ b/modules/zfssnap/zfssnap/zfssnap/__main__.py
@@ -45,6 +45,9 @@ TIME_PATTERNS = OrderedDict([
45 ("yearly", lambda t: t.strftime('%Y')), 45 ("yearly", lambda t: t.strftime('%Y')),
46]) 46])
47 47
48PROP_DO_AUTO_SNAPSHOT = 'li.yggdrasil:auto-snapshot'
49PROP_IS_AUTO_SNAPSHOT = 'li.yggdrasil:is-auto-snapshot'
50
48@dataclass(eq=True, order=True, frozen=True) 51@dataclass(eq=True, order=True, frozen=True)
49class Snap: 52class Snap:
50 name: str 53 name: str
@@ -68,25 +71,28 @@ def _snap_name(item, time=_now()):
68 71
69def _log_cmd(*args): 72def _log_cmd(*args):
70 fmt_args = ' '.join(map(shlex.quote, args)) 73 fmt_args = ' '.join(map(shlex.quote, args))
71 logger.debug(f'Running command: {fmt_args}') 74 logger.debug('Running command: %s', fmt_args)
72 75
73def _get_items(): 76def _get_items():
74 items = {} 77 items = {}
75 78
76 args = ['zfs', 'get', '-H', '-p', '-o', 'name,value', '-t', 'filesystem,volume', '-s', 'local,default,inherited,temporary,received', 'li.yggdrasil:auto-snapshot'] 79 args = ['zfs', 'get', '-H', '-p', '-o', 'name,value', '-t', 'filesystem,volume', PROP_DO_AUTO_SNAPSHOT]
77 _log_cmd(*args) 80 _log_cmd(*args)
78 with subprocess.Popen(args, stdout=subprocess.PIPE) as proc: 81 with subprocess.Popen(args, stdout=subprocess.PIPE) as proc:
79 text_stdout = io.TextIOWrapper(proc.stdout) 82 text_stdout = io.TextIOWrapper(proc.stdout)
80 reader = csv.DictReader(text_stdout, fieldnames=['name', 'setting'], delimiter='\t', quoting=csv.QUOTE_NONE) 83 reader = csv.DictReader(text_stdout, fieldnames=['name', 'value'], delimiter='\t', quoting=csv.QUOTE_NONE)
81 Row = namedtuple('Row', reader.fieldnames) 84 Row = namedtuple('Row', reader.fieldnames)
82 for row in [Row(**data) for data in reader]: 85 for row in [Row(**data) for data in reader]:
83 items[row.name] = bool(strtobool(row.setting)) 86 if not row.value or row.value == '-':
87 continue
88
89 items[row.name] = bool(strtobool(row.value))
84 90
85 return items 91 return items
86 92
87def _get_snaps(only_auto=True): 93def _get_snaps(only_auto=True):
88 snapshots = defaultdict(list) 94 snapshots = defaultdict(list)
89 args = ['zfs', 'list', '-H', '-p', '-t', 'snapshot', '-o', 'name,li.yggdrasil:is-auto-snapshot,creation'] 95 args = ['zfs', 'list', '-H', '-p', '-t', 'snapshot', '-o', f'name,{PROP_IS_AUTO_SNAPSHOT},creation']
90 _log_cmd(*args) 96 _log_cmd(*args)
91 with subprocess.Popen(args, stdout=subprocess.PIPE) as proc: 97 with subprocess.Popen(args, stdout=subprocess.PIPE) as proc:
92 text_stdout = io.TextIOWrapper(proc.stdout) 98 text_stdout = io.TextIOWrapper(proc.stdout)
@@ -102,17 +108,26 @@ def _get_snaps(only_auto=True):
102 108
103 return snapshots 109 return snapshots
104 110
105def prune(config, dry_run, keep_newest, do_exec): 111def prune(config, dry_run, keep_newest, do_exec, exec_newest):
106 do_exec = do_exec and 'EXEC' in config 112 do_exec = do_exec and 'EXEC' in config
107 prune_timezone = config.gettimezone('KEEP', 'timezone', fallback=tzutc()) 113 prune_timezone = config.gettimezone('KEEP', 'timezone', fallback=tzutc())
108 logger.debug(f'prune timezone: {prune_timezone}') 114 logger.debug('prune timezone: %s', prune_timezone)
109 115
110 items = _get_snaps() 116 items = _get_snaps()
111 117
118 kept_count = defaultdict(lambda: defaultdict(lambda: 0))
119 kept_because = OrderedDict()
120 def keep_because(base, snap, rule, period=None):
121 nonlocal kept_count, kept_because
122 kept_count[rule][base] += 1
123 if snap not in kept_because:
124 kept_because[snap] = deque()
125 kept_because[snap].append(KeptBecause(rule=rule, ix=kept_count[rule][base], base=base, period=period))
126
112 exec_candidates = set() 127 exec_candidates = set()
113 if do_exec: 128 if do_exec:
114 exec_timezone = config.gettimezone('EXEC', 'timezone', fallback=prune_timezone) 129 exec_timezone = config.gettimezone('EXEC', 'timezone', fallback=prune_timezone)
115 logger.debug(f'exec timezone: {exec_timezone}') 130 logger.debug('exec timezone: %s', exec_timezone)
116 131
117 for rule, pattern in TIME_PATTERNS.items(): 132 for rule, pattern in TIME_PATTERNS.items():
118 desired_count = config.getint('EXEC', rule, fallback=0) 133 desired_count = config.getint('EXEC', rule, fallback=0)
@@ -120,7 +135,7 @@ def prune(config, dry_run, keep_newest, do_exec):
120 for base, snaps in items.items(): 135 for base, snaps in items.items():
121 periods = OrderedDict() 136 periods = OrderedDict()
122 137
123 for snap in sorted(snaps, key=lambda snap: snap.creation): 138 for snap in sorted(snaps, key=lambda snap: snap.creation, reverse=exec_newest):
124 period = pattern(snap.creation.astimezone(exec_timezone)) 139 period = pattern(snap.creation.astimezone(exec_timezone))
125 if period not in periods: 140 if period not in periods:
126 periods[period] = deque() 141 periods[period] = deque()
@@ -134,15 +149,16 @@ def prune(config, dry_run, keep_newest, do_exec):
134 149
135 for snap in period_snaps: 150 for snap in period_snaps:
136 exec_candidates.add(snap) 151 exec_candidates.add(snap)
137 logger.debug(f'{snap.name} is exec candidate') 152 logger.debug('‘%s is exec candidate', snap.name)
138 to_exec -= 1 153 to_exec -= 1
139 break 154 break
140 155
141 if to_exec > 0: 156 if to_exec > 0:
142 logger.debug(f'Missing {to_exec} to fulfill exec {rule}={desired_count} for ‘{base}’') 157 logger.debug('Missing %d to fulfill exec %s=%d for ‘%s’', to_exec, rule, desired_count, base)
143 158
144 check_cmd = config.get('EXEC', 'check', fallback=None) 159 check_cmd = config.get('EXEC', 'check', fallback=None)
145 if check_cmd: 160 if check_cmd:
161 logger.debug('exec_candidates=%s', exec_candidates)
146 already_execed = set() 162 already_execed = set()
147 for snap in exec_candidates: 163 for snap in exec_candidates:
148 args = [] 164 args = []
@@ -152,7 +168,20 @@ def prune(config, dry_run, keep_newest, do_exec):
152 check_res = subprocess.run(args) 168 check_res = subprocess.run(args)
153 if check_res.returncode == 0: 169 if check_res.returncode == 0:
154 already_execed.add(snap) 170 already_execed.add(snap)
155 logger.debug(f'{snap.name} already execed') 171 logger.debug('‘%s’ already execed', snap.name)
172 elif check_res.returncode == 124:
173 already_execed.add(snap)
174 logger.warn('‘%s’ ignored', snap.name)
175 pass
176 elif check_res.returncode == 125:
177 already_execed.add(snap)
178 logger.info('‘%s’ ignored but specified for keeping, doing so...', snap.name)
179 base_name, _, _ = snap.name.rpartition('@')
180 keep_because(base_name, snap.name, 'exec-ignored')
181 elif check_res.returncode == 126:
182 logger.debug('‘%s’ to exec', snap.name)
183 else:
184 check_res.check_returncode()
156 exec_candidates -= already_execed 185 exec_candidates -= already_execed
157 186
158 exec_cmd = config.get('EXEC', 'cmd', fallback=None) 187 exec_cmd = config.get('EXEC', 'cmd', fallback=None)
@@ -160,28 +189,26 @@ def prune(config, dry_run, keep_newest, do_exec):
160 if exec_cmd: 189 if exec_cmd:
161 execed = set() 190 execed = set()
162 for snap in sorted(exec_candidates, key=lambda snap: snap.creation): 191 for snap in sorted(exec_candidates, key=lambda snap: snap.creation):
163 if len(execed) >= exec_count: 192 if exec_count > 0 and len(execed) >= exec_count:
164 logger.debug(f'exc_count of {exec_count} reached') 193 logger.debug('exec_count of %d reached', exec_count)
165 break 194 break
166 195
167 args = [] 196 args = []
168 args += shlex.split(exec_cmd) 197 args += shlex.split(exec_cmd)
169 args += [snap.name] 198 args += [snap.name]
170 _log_cmd(*args) 199 _log_cmd(*args)
171 subprocess.run(args).check_returncode() 200 p = subprocess.run(args)
201 if p.returncode == 125:
202 logger.warn('got dry-run returncode for ‘%s’, keeping...', snap.name)
203 base_name, _, _ = snap.name.rpartition('@')
204 keep_because(base_name, snap.name, 'exec-dryrun')
205 pass
206 else:
207 p.check_returncode()
172 execed.add(snap) 208 execed.add(snap)
173 209
174 exec_candidates -= execed 210 exec_candidates -= execed
175 211
176 kept_count = defaultdict(lambda: defaultdict(lambda: 0))
177 kept_because = OrderedDict()
178 def keep_because(base, snap, rule, period=None):
179 nonlocal kept_count, kept_because
180 kept_count[rule][base] += 1
181 if snap not in kept_because:
182 kept_because[snap] = deque()
183 kept_because[snap].append(KeptBecause(rule=rule, ix=kept_count[rule][base], base=base, period=period))
184
185 for candidate in exec_candidates: 212 for candidate in exec_candidates:
186 base_name, _, _ = candidate.name.rpartition('@') 213 base_name, _, _ = candidate.name.rpartition('@')
187 keep_because(base_name, candidate.name, 'exec-candidate') 214 keep_because(base_name, candidate.name, 'exec-candidate')
@@ -191,10 +218,10 @@ def prune(config, dry_run, keep_newest, do_exec):
191 for base, snaps in items.items(): 218 for base, snaps in items.items():
192 time_ref = max(snaps, key=lambda snap: snap.creation, default=None) 219 time_ref = max(snaps, key=lambda snap: snap.creation, default=None)
193 if not time_ref: 220 if not time_ref:
194 logger.warn(f'Nothing to keep for ‘{base}’') 221 logger.warn('Nothing to keep for ‘%s’', base)
195 continue 222 continue
196 223
197 logger.info(f'Using ‘{time_ref.name}’ as time reference for ‘{base}’') 224 logger.info('Using ‘%s’ as time reference for ‘%s’', time_ref.name, base)
198 within_cutoff = time_ref.creation - within 225 within_cutoff = time_ref.creation - within
199 226
200 for snap in snaps: 227 for snap in snaps:
@@ -227,11 +254,10 @@ def prune(config, dry_run, keep_newest, do_exec):
227 break 254 break
228 255
229 if to_keep > 0: 256 if to_keep > 0:
230 logger.debug(f'Missing {to_keep} to fulfill prune {rule}={desired_count} for ‘{base}’') 257 logger.debug('Missing %d to fulfill prune %s=%d for ‘%s’', to_keep, rule, desired_count, base)
231 258
232 for snap, reasons in kept_because.items(): 259 for snap, reasons in kept_because.items():
233 reasons_str = ', '.join(map(str, reasons)) 260 logger.info('Keeping ‘%s’ because: %s', snap, ', '.join(map(str, reasons)))
234 logger.info(f'Keeping ‘{snap}’ because: {reasons_str}')
235 all_snaps = {snap.name for _, snaps in items.items() for snap in snaps} 261 all_snaps = {snap.name for _, snaps in items.items() for snap in snaps}
236 to_destroy = all_snaps - {*kept_because} 262 to_destroy = all_snaps - {*kept_because}
237 if not to_destroy: 263 if not to_destroy:
@@ -245,9 +271,9 @@ def prune(config, dry_run, keep_newest, do_exec):
245 _log_cmd(*args) 271 _log_cmd(*args)
246 subprocess.run(args, check=True) 272 subprocess.run(args, check=True)
247 if dry_run: 273 if dry_run:
248 logger.info(f'Would have pruned ‘{snap}’') 274 logger.info('Would have pruned ‘%s’', snap)
249 else: 275 else:
250 logger.info(f'Pruned ‘{snap}’') 276 logger.info('Pruned ‘%s’', snap)
251 277
252def rename(snapshots, destroy=False, set_is_auto=False): 278def rename(snapshots, destroy=False, set_is_auto=False):
253 args = ['zfs', 'get', '-H', '-p', '-o', 'name,value', 'creation', *snapshots] 279 args = ['zfs', 'get', '-H', '-p', '-o', 'name,value', 'creation', *snapshots]
@@ -262,29 +288,29 @@ def rename(snapshots, destroy=False, set_is_auto=False):
262 base_name, _, _ = row.name.rpartition('@') 288 base_name, _, _ = row.name.rpartition('@')
263 new_name = _snap_name(base_name, time=creation) 289 new_name = _snap_name(base_name, time=creation)
264 if new_name == row.name: 290 if new_name == row.name:
265 logger.debug(f'Not renaming ‘{row.name}’ since name is already correct') 291 logger.debug('Not renaming ‘%s’ since name is already correct', row.name)
266 continue 292 continue
267 293
268 if new_name in renamed_to: 294 if new_name in renamed_to:
269 if destroy: 295 if destroy:
270 logger.warning(f'Destroying ‘{row.name}’ since ‘{new_name}’ was already renamed to') 296 logger.warning('Destroying ‘%s’ since ‘%s’ was already renamed to', row.name, new_name)
271 args = ['zfs', 'destroy', row.name] 297 args = ['zfs', 'destroy', row.name]
272 _log_cmd(*args) 298 _log_cmd(*args)
273 subprocess.run(args, check=True) 299 subprocess.run(args, check=True)
274 else: 300 else:
275 logger.info(f'Skipping ‘{row.name}’ since ‘{new_name}’ was already renamed to') 301 logger.info('Skipping ‘%s’ since ‘%s’ was already renamed to', row.name, new_name)
276 302
277 continue 303 continue
278 304
279 logger.info(f'Renaming ‘{row.name}’ to ‘{new_name}’') 305 logger.info('Renaming ‘%s’ to ‘%s’', row.name, new_name)
280 args = ['zfs', 'rename', row.name, new_name] 306 args = ['zfs', 'rename', row.name, new_name]
281 _log_cmd(*args) 307 _log_cmd(*args)
282 subprocess.run(args, check=True) 308 subprocess.run(args, check=True)
283 renamed_to.add(new_name) 309 renamed_to.add(new_name)
284 310
285 if set_is_auto: 311 if set_is_auto:
286 logger.info(f'Setting is-auto-snapshot on ‘{new_name}’') 312 logger.info('Setting is-auto-snapshot on ‘%s’', new_name)
287 args = ['zfs', 'set', 'li.yggdrasil:is-auto-snapshot=true', new_name] 313 args = ['zfs', 'set', f'{PROP_IS_AUTO_SNAPSHOT}=true', new_name]
288 _log_cmd(*args) 314 _log_cmd(*args)
289 subprocess.run(args, check=True) 315 subprocess.run(args, check=True)
290 316
@@ -301,7 +327,7 @@ def autosnap():
301 else: 327 else:
302 all_snap_names |= snap_names 328 all_snap_names |= snap_names
303 329
304 args = ['zfs', 'snapshot', '-o', 'li.yggdrasil:is-auto-snapshot=true'] 330 args = ['zfs', 'snapshot', '-o', f'{PROP_IS_AUTO_SNAPSHOT}=true']
305 if recursive: 331 if recursive:
306 args += ['-r'] 332 args += ['-r']
307 args += snap_names 333 args += snap_names
@@ -324,7 +350,7 @@ def autosnap():
324 await asyncio.gather(*tasks) 350 await asyncio.gather(*tasks)
325 asyncio.run(run_tasks()) 351 asyncio.run(run_tasks())
326 for snap in all_snap_names: 352 for snap in all_snap_names:
327 logger.info(f'Created ‘{snap}’') 353 logger.info('Created ‘%s’', snap)
328 if all_snap_names: 354 if all_snap_names:
329 rename(snapshots=all_snap_names) 355 rename(snapshots=all_snap_names)
330 356
@@ -347,7 +373,8 @@ def main():
347 sys.excepthook = log_exceptions 373 sys.excepthook = log_exceptions
348 374
349 parser = argparse.ArgumentParser(prog='zfssnap') 375 parser = argparse.ArgumentParser(prog='zfssnap')
350 parser.add_argument('--verbose', '-v', action='count', default=0) 376 parser.add_argument('--verbose', '-v', dest='log_level', action='append_const', const=-1)
377 parser.add_argument('--quiet', '-q', dest='log_level', action='append_const', const=1)
351 subparsers = parser.add_subparsers() 378 subparsers = parser.add_subparsers()
352 parser.set_defaults(cmd=autosnap) 379 parser.set_defaults(cmd=autosnap)
353 rename_parser = subparsers.add_parser('rename') 380 rename_parser = subparsers.add_parser('rename')
@@ -359,26 +386,29 @@ def main():
359 prune_parser.add_argument('--config', '-c', dest='config_files', nargs='*', default=list()) 386 prune_parser.add_argument('--config', '-c', dest='config_files', nargs='*', default=list())
360 prune_parser.add_argument('--dry-run', '-n', action='store_true', default=False) 387 prune_parser.add_argument('--dry-run', '-n', action='store_true', default=False)
361 prune_parser.add_argument('--keep-newest', action='store_true', default=False) 388 prune_parser.add_argument('--keep-newest', action='store_true', default=False)
389 prune_parser.add_argument('--exec-newest', action='store_true', default=False)
362 prune_parser.add_argument('--no-exec', dest='do_exec', action='store_false', default=True) 390 prune_parser.add_argument('--no-exec', dest='do_exec', action='store_false', default=True)
363 prune_parser.set_defaults(cmd=prune) 391 prune_parser.set_defaults(cmd=prune)
364 args = parser.parse_args() 392 args = parser.parse_args()
365 393
366 if args.verbose <= 0: 394
367 logger.setLevel(logging.WARNING) 395 LOG_LEVELS = [logging.DEBUG, logging.INFO, logging.WARNING, logging.ERROR, logging.CRITICAL]
368 elif args.verbose <= 1: 396 DEFAULT_LOG_LEVEL = logging.ERROR
369 logger.setLevel(logging.INFO) 397 log_level = LOG_LEVELS.index(DEFAULT_LOG_LEVEL)
370 else: 398
371 logger.setLevel(logging.DEBUG) 399 for adjustment in args.log_level or ():
400 log_level = min(len(LOG_LEVELS) - 1, max(log_level + adjustment, 0))
401 logger.setLevel(LOG_LEVELS[log_level])
372 402
373 cmdArgs = {} 403 cmdArgs = {}
374 for copy in {'snapshots', 'dry_run', 'destroy', 'keep_newest', 'set_is_auto', 'do_exec'}: 404 for copy in {'snapshots', 'dry_run', 'destroy', 'keep_newest', 'exec_newest', 'set_is_auto', 'do_exec'}:
375 if copy in vars(args): 405 if copy in vars(args):
376 cmdArgs[copy] = vars(args)[copy] 406 cmdArgs[copy] = vars(args)[copy]
377 if 'config_files' in vars(args): 407 if 'config_files' in vars(args):
378 def convert_timedelta(secs_str): 408 def convert_timedelta(secs_str):
379 secs=pytimeparse.parse(secs_str) 409 secs=pytimeparse.parse(secs_str)
380 if secs is None: 410 if secs is None:
381 raise ValueError(f'Could not parse timedelta expression ‘{secs_str}’') 411 raise ValueError('Could not parse timedelta expression ‘%s’', secs_str)
382 return timedelta(seconds=secs) 412 return timedelta(seconds=secs)
383 config = configparser.ConfigParser(converters={ 413 config = configparser.ConfigParser(converters={
384 'timedelta': convert_timedelta, 414 'timedelta': convert_timedelta,
@@ -393,9 +423,9 @@ def main():
393 return ', '.join(map(lambda file: f'‘{file}’', files)) 423 return ', '.join(map(lambda file: f'‘{file}’', files))
394 424
395 if not read_files: 425 if not read_files:
396 raise Exception(f'Found no config files. Tried: {format_config_files(search_files)}') 426 raise Exception('Found no config files. Tried: %s', format_config_files(search_files))
397 427
398 logger.debug(f'Read following config files: {format_config_files(read_files)}') 428 logger.debug('Read following config files: %s', format_config_files(read_files))
399 429
400 cmdArgs['config'] = config 430 cmdArgs['config'] = config
401 431