ppkit 0.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.
pp/commands/batch.py ADDED
@@ -0,0 +1,848 @@
1
+ r"""Rename files in bulk or run a command per file
2
+
3
+ Everything previews first: nothing is renamed or run until you say yes (or pass -y); -n only shows the plan.
4
+ pp expands globs itself, so quote them and they behave the same in zsh, bash or a script; '**' searches
5
+ subfolders too.
6
+
7
+ RENAME is a regex FIND applied to each name, replaced by REPLACE:
8
+ pp batch rename '\.jpeg$' '.jpg' '*.jpeg' change an extension
9
+ pp batch rename ' ' '_' '*' spaces -> underscores
10
+ pp batch rename '^IMG_(\d+)' 'photo-\1' '*.jpg' \1 is whatever (\d+) matched
11
+ pp batch rename -i 'draft' 'final' --stem '*.md' ignore case; never touch the extension
12
+ ...or build the whole new name from a template (-t):
13
+ pp batch rename -t '{parent}_{i:03}{ext}' 'photos/**/*.jpg' trip_001.jpg, trip_002.jpg, ... in each folder
14
+ pp batch rename -t '{stem|slug}{ext}' '*.md' "My Notes (v2).md" -> my-notes-v2.md
15
+ A REPLACE that starts with - goes after --, and so does everything after it; a template that starts with - is
16
+ written --template=-... or -t-...:
17
+ pp batch rename -- '^' '-draft-' '*.md' a.md -> -draft-a.md
18
+ pp batch undo put the last rename back (again: the one before)
19
+ pp batch undo --list what undo can see
20
+ pp batch undo --partial some were moved or deleted since: restore the rest
21
+
22
+ RUN is one command per file. Fields are quoted for you, so names with spaces or quotes are safe; the rest of
23
+ the command is ordinary shell (pipes and > work):
24
+ pp batch run 'magick {} {base}.png' '*.webp'
25
+ pp batch run 'ffmpeg -i {} {out}' '*.wav' --out 'mp3/{stem}.mp3' -j 4 skips files already converted
26
+ (a command that fails has the {out} it started removed, so a rerun redoes it)
27
+ pp batch run 'wc -l {}' 'src/**/*.py'
28
+ pp batch run @webp '*.png' a preset from [batch.presets] in ~/.config/pp/config.toml
29
+ pp batch presets list presets
30
+ Presets: [batch.presets]
31
+ webp = { cmd = "cwebp -q 85 {} -o {out}", out = "{base}.webp" }
32
+ lines = "wc -l {}"
33
+
34
+ TEMPLATES are {field}, {field:format}, {field|filter|filter}; write {{ and }} for literal braces
35
+ fields path name stem ext dir parent base n N i mtime (+ {out} with --out; + {1} {2} {name} regex groups)
36
+ for photos/trip/IMG 7.JPG: name=IMG 7.JPG stem=IMG 7 ext=.JPG dir=photos/trip parent=trip
37
+ base=photos/trip/IMG 7 n=position in the list (1, 2, ...; --start K) N=how many
38
+ i=position among the items in the same folder mtime=20261008
39
+ formats {n:03} -> 007 {stem:.10} -> first 10 characters
40
+ filters lower upper title strip slug (my-notes-v2) snake (my_notes_v2)
41
+ {} alone means {path}.
42
+
43
+ PATHS are files, folders, or quoted globs. A folder is an item itself; -r means everything inside it instead
44
+ (hidden entries only with --hidden; .git node_modules __pycache__ .venv never). Narrow with --type f|d and
45
+ --ext jpg,png.
46
+
47
+ Rename refuses when two items would get the same name or a new name is already taken, and lists every problem
48
+ at once. Run refuses the same way when two items would write the same {out}, since the one that finishes last
49
+ would win. Swaps (a<->b) and renaming a folder together with what is inside it are handled. Every applied
50
+ rename is journaled under ~/.local/state/pp/batch/journal; that journal is what `undo` reverses.
51
+ """
52
+ from __future__ import annotations
53
+
54
+ import glob
55
+ import json
56
+ import os
57
+ import re
58
+ import shlex
59
+ import sys
60
+ import threading
61
+ import time
62
+ import unicodedata
63
+ from concurrent.futures import ThreadPoolExecutor
64
+ from contextlib import nullcontext
65
+ from datetime import datetime
66
+
67
+ from pp.lib import config, lock, paths, proc, ui
68
+
69
+ PRUNE = {".git", "node_modules", "__pycache__", ".venv"}
70
+ FIELDS = {"path", "name", "stem", "ext", "dir", "parent", "base", "n", "N", "i", "mtime"}
71
+ PATHLIKE = {"path", "dir", "base", "out"} # quoted into a command: one starting with "-" gets "./" in front
72
+
73
+
74
+ class TemplateError(Exception):
75
+ pass
76
+
77
+
78
+ def _shown(text: str) -> str:
79
+ """A name off the disk, a command built from one, or a command's own output, as batch prints it.
80
+
81
+ Every name here comes from somewhere else, and batch prints the plan it is about to carry out and then
82
+ what became of each item. A control character in a name would redraw the terminal the plan is read in, so
83
+ `\\x1b[2J` in a file name clears the screen holding the plan the user is about to say yes to. ui.printable
84
+ shows it instead. Tabs are shown too, since a tab would break the columns a preview lines up in."""
85
+ return ui.printable(text, tabs=False)
86
+
87
+
88
+ # ---- collecting items -----------------------------------------------------------------------------
89
+
90
+ def _natkey(s: str) -> list:
91
+ # alternating str/int, always str first, so keys compare position by position without type clashes
92
+ return [int(t) if t.isdigit() else t.lower() for t in re.split(r"(\d+)", s)]
93
+
94
+
95
+ def _pruned(path: str, arg: str) -> bool:
96
+ """A glob match that is or sits inside node_modules & co., unless the pattern itself names that directory."""
97
+ return any(part in PRUNE and part not in arg for part in os.path.normpath(path).split(os.sep))
98
+
99
+
100
+ def _walk(top: str, hidden: bool) -> list[str]:
101
+ out = []
102
+ for d, dirs, files in os.walk(top):
103
+ dirs[:] = sorted((x for x in dirs if x not in PRUNE and (hidden or not x.startswith("."))), key=_natkey)
104
+ out += [os.path.join(d, x) for x in dirs]
105
+ out += [os.path.join(d, x) for x in sorted(files, key=_natkey) if hidden or not x.startswith(".")]
106
+ return sorted(out, key=_natkey)
107
+
108
+
109
+ def collect(args: list[str], recursive=False, hidden=False, type_=None, exts=None) -> list[str]:
110
+ """Items from file/dir/glob args, in argument order, each glob naturally sorted, duplicates dropped."""
111
+ exts = {e.lower().lstrip(".") for e in exts.split(",") if e} if exts else None
112
+ items, seen = [], set()
113
+ for arg in args:
114
+ if os.path.lexists(arg):
115
+ matches = [arg]
116
+ elif set("*?[") & set(arg):
117
+ matches = [m for m in sorted(glob.glob(arg, recursive=True, include_hidden=hidden), key=_natkey)
118
+ if not _pruned(m, arg)]
119
+ if not matches:
120
+ ui.warn(f"nothing matches {_shown(arg)}")
121
+ else:
122
+ ui.warn(f"no such file or folder: {_shown(arg)}")
123
+ matches = []
124
+ for m in matches:
125
+ for p in (_walk(m, hidden) if recursive and os.path.isdir(m) and not os.path.islink(m) else [m]):
126
+ key = os.path.abspath(p)
127
+ if key in seen:
128
+ continue
129
+ if type_ == "f" and os.path.isdir(p) or type_ == "d" and not os.path.isdir(p):
130
+ continue
131
+ if exts is not None and (os.path.isdir(p) or os.path.splitext(p)[1].lower().lstrip(".") not in exts):
132
+ continue
133
+ seen.add(key)
134
+ items.append(p)
135
+ return items
136
+
137
+
138
+ def _fields(path: str, n: int, N: int, i: int) -> dict:
139
+ norm = os.path.normpath(path)
140
+ name = os.path.basename(norm)
141
+ stem, ext = os.path.splitext(name)
142
+ d = os.path.dirname(norm)
143
+ try:
144
+ mtime = time.strftime("%Y%m%d", time.localtime(os.lstat(path).st_mtime))
145
+ except OSError:
146
+ mtime = ""
147
+ return {"path": path, "name": name, "stem": stem, "ext": ext, "dir": d or ".",
148
+ "parent": os.path.basename(os.path.abspath(d or ".")), "base": os.path.join(d, stem) if d else stem,
149
+ "n": n, "N": N, "i": i, "mtime": mtime}
150
+
151
+
152
+ def _positions(items: list[str], start: int) -> list[int]:
153
+ """{i} for each item: its position among the items in the same folder (start, start+1, ...)."""
154
+ seen: dict = {}
155
+ out = []
156
+ for p in items:
157
+ d = os.path.dirname(os.path.abspath(os.path.normpath(p)))
158
+ seen[d] = seen.get(d, start - 1) + 1
159
+ out.append(seen[d])
160
+ return out
161
+
162
+
163
+ # ---- templates ------------------------------------------------------------------------------------
164
+
165
+ def _slug(s: str, sep: str) -> str:
166
+ s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode()
167
+ return re.sub(r"[^a-z0-9]+", sep, s.lower()).strip(sep)
168
+
169
+
170
+ FILTERS = {"lower": str.lower, "upper": str.upper, "title": str.title, "strip": str.strip,
171
+ "slug": lambda s: _slug(s, "-"), "snake": lambda s: _slug(s, "_")}
172
+ _TOK = re.compile(r"\{\{|\}\}|\{([^{}]*)\}|[{}]")
173
+
174
+
175
+ def compile_template(text: str, allowed: set[str]) -> list:
176
+ """Parse once, validating every field and filter up front: [literal str | (field, [filters], fmt)]."""
177
+ parts, buf, pos = [], [], 0
178
+ for m in _TOK.finditer(text):
179
+ buf.append(text[pos:m.start()])
180
+ pos = m.end()
181
+ tok = m.group(0)
182
+ if tok in ("{{", "}}"):
183
+ buf.append(tok[0])
184
+ elif m.group(1) is None:
185
+ raise TemplateError(f"unmatched '{tok}' in {text!r} (write {tok * 2} for a literal brace)")
186
+ else:
187
+ if buf:
188
+ parts.append("".join(buf))
189
+ buf = []
190
+ head, _, fmt = m.group(1).partition(":")
191
+ field, *filters = [x.strip() for x in head.split("|")]
192
+ field = field or "path"
193
+ if field not in allowed:
194
+ hint = (" (regex groups exist only in FIND REPLACE mode)" if field.isdigit()
195
+ else " ({out} exists only with --out)" if field == "out" else "")
196
+ raise TemplateError(f"unknown field {{{field}}}{hint}; fields: {' '.join(sorted(allowed, key=lambda f: (f.isdigit(), f.lower(), f)))}")
197
+ for fl in filters:
198
+ if fl not in FILTERS:
199
+ raise TemplateError(f"unknown filter |{fl} in {{{m.group(1)}}}; filters: {' '.join(FILTERS)}")
200
+ parts.append((field, filters, fmt))
201
+ buf.append(text[pos:])
202
+ if "".join(buf):
203
+ parts.append("".join(buf))
204
+ return parts
205
+
206
+
207
+ def _shell_state(text: str, state: str | None) -> str | None:
208
+ """The quoting a shell is in after reading text, starting in state: None, "'" or '"'."""
209
+ i = 0
210
+ while i < len(text):
211
+ ch = text[i]
212
+ if ch == "\\" and state != "'":
213
+ i += 1 # the next character is escaped
214
+ elif state is None and ch in "'\"" or ch == state:
215
+ state = None if ch == state else ch
216
+ i += 1
217
+ return state
218
+
219
+
220
+ def _shell_quote(s: str, state: str | None) -> str:
221
+ """s as one literal word where the command has it: bare, or inside the user's own '...' or "...". So
222
+ `rm '{}'` (the find -exec habit) removes `my file`, not `my` and `file`."""
223
+ if state == "'":
224
+ return s.replace("'", "'\\''")
225
+ if state == '"':
226
+ return re.sub(r'([\\"$`])', r"\\\1", s)
227
+ return shlex.quote(s)
228
+
229
+
230
+ def render(parts: list, fields: dict, quote: bool = False, expand=None) -> str:
231
+ """Fill a compiled template. quote: shell-quote each substituted value (for the quoting it sits in). expand:
232
+ applied to literal parts (rename passes match.expand, so \\1 and \\g<name> work between the {fields})."""
233
+ out = []
234
+ state = None
235
+ for p in parts:
236
+ if isinstance(p, str):
237
+ try:
238
+ out.append(expand(p) if expand else p)
239
+ except (re.error, IndexError) as e:
240
+ raise TemplateError(f"in {p!r}: {e}") from None
241
+ if quote:
242
+ state = _shell_state(out[-1], state)
243
+ continue
244
+ field, filters, fmt = p
245
+ v = fields[field]
246
+ if filters:
247
+ v = str(v)
248
+ for fl in filters:
249
+ v = FILTERS[fl](v)
250
+ try:
251
+ s = format(v, fmt)
252
+ except (ValueError, TypeError) as e:
253
+ raise TemplateError(f"{{{field}:{fmt}}}: {e}") from None
254
+ if quote and field in PATHLIKE and s.startswith("-"):
255
+ s = "./" + s # else `cat {}` on a file named -n.txt is `cat -n.txt`: an option, not a file
256
+ out.append(_shell_quote(s, state) if quote else s)
257
+ return "".join(out)
258
+
259
+
260
+ # ---- rename: plan ---------------------------------------------------------------------------------
261
+
262
+ def _hl(a: str, b: str) -> tuple[str, str]:
263
+ """Colour the part that differs between two strings (common prefix and suffix stay plain)."""
264
+ i = 0
265
+ while i < min(len(a), len(b)) and a[i] == b[i]:
266
+ i += 1
267
+ j = 0
268
+ while j < min(len(a), len(b)) - i and a[-1 - j] == b[-1 - j]:
269
+ j += 1
270
+ mid = lambda s, col: s[:i] + (ui.c(s[i:len(s) - j], col) if s[i:len(s) - j] else "") + s[len(s) - j:]
271
+ return mid(a, "red"), mid(b, "green")
272
+
273
+
274
+ def _print_pairs(pairs: list[tuple[str, str]]):
275
+ # the names as they are printed, and then in printed columns, so a CJK or emoji name, which takes two
276
+ # columns per character, lines up with an ASCII one: this is the preview the user approves before
277
+ # anything is renamed, and `pp batch undo` previews the way back the same way
278
+ pairs = [(_shown(a), _shown(b)) for a, b in pairs]
279
+ w = min(max((ui.width(a) for a, _ in pairs), default=0), 60)
280
+ for a, b in pairs:
281
+ ha, hb = _hl(a, b)
282
+ print(f" {ui.pad(ha, w)} -> {hb}")
283
+
284
+
285
+ def prepare_rename(find: str | None, replace: str, ignore_case=False):
286
+ """(regex or None, compiled REPLACE), checked before any path is looked at, so a typo fails first."""
287
+ rx = None
288
+ allowed = FIELDS
289
+ if find is not None:
290
+ try:
291
+ rx = re.compile(find, re.IGNORECASE if ignore_case else 0)
292
+ except re.error as e:
293
+ raise TemplateError(f"bad regex {find!r}: {e}") from None
294
+ allowed = FIELDS | {str(i) for i in range(rx.groups + 1)} | set(rx.groupindex)
295
+ return rx, compile_template(replace, allowed)
296
+
297
+
298
+ def plan_rename(items: list[str], rx, parts: list, stem_only=False, start=1):
299
+ """-> (changes, unchanged_count, problems). A change: {path, target, parent, old, new, depth}."""
300
+ changes, unchanged = [], 0
301
+ for i, (path, k) in enumerate(zip(items, _positions(items, start))):
302
+ f = _fields(path, i + start, len(items), k)
303
+ subject = f["stem"] if stem_only else f["name"]
304
+ if rx is None:
305
+ new = render(parts, f)
306
+ else:
307
+ def sub(m, f=f):
308
+ groups = {str(k): m.group(k) or "" for k in range(rx.groups + 1)}
309
+ groups.update({k: m.group(k) or "" for k in rx.groupindex})
310
+ return render(parts, {**f, **groups}, expand=m.expand)
311
+ new = rx.sub(sub, subject)
312
+ if stem_only:
313
+ new += f["ext"]
314
+ if new == f["name"]:
315
+ unchanged += 1
316
+ continue
317
+ parent = os.path.dirname(os.path.abspath(path))
318
+ changes.append({"path": path, "target": os.path.join(os.path.dirname(os.path.normpath(path)), new),
319
+ "parent": parent, "old": f["name"], "new": new, "depth": parent.count(os.sep)})
320
+ problems = []
321
+ sources = {(c["parent"], c["old"]) for c in changes}
322
+ by_target: dict = {}
323
+ for c in changes:
324
+ if not c["new"] or "/" in c["new"] or "\0" in c["new"] or c["new"] in (".", ".."):
325
+ problems.append(f"{c['path']}: {c['new']!r} is not a valid name")
326
+ continue
327
+ by_target.setdefault((c["parent"], c["new"]), []).append(c)
328
+ for (parent, new), cs in by_target.items():
329
+ if len(cs) > 1:
330
+ problems.append(f"{', '.join(c['path'] for c in cs)} would all become {cs[0]['target']}")
331
+ elif os.path.lexists(os.path.join(parent, new)) and (parent, new) not in sources:
332
+ problems.append(f"{cs[0]['target']} already exists (wanted by {cs[0]['path']})")
333
+ return changes, unchanged, problems
334
+
335
+
336
+ # ---- rename: execution ----------------------------------------------------------------------------
337
+
338
+ def _mv(src: str, dst: str, done: list):
339
+ # os.rename silently replaces an existing file; the plan checked, but the disk may have moved on since
340
+ if os.path.lexists(dst):
341
+ raise FileExistsError(f"{dst} appeared after the plan was made")
342
+ os.rename(src, dst)
343
+ done.append((src, dst))
344
+
345
+
346
+ def _roll_back(done: list) -> list:
347
+ """Put every step in done back, the last one first, and take the ones that went back off the list, so what
348
+ is left on it is what is still on disk. -> the steps that could not go back.
349
+
350
+ The signals `lock.deferring` holds back wait for this, since a rollback stopped half way would leave a file at
351
+ a temporary name. It is as long as what the run has already done."""
352
+ left = []
353
+ with lock.deferring():
354
+ for src, dst in reversed(done):
355
+ try:
356
+ os.rename(dst, src)
357
+ except OSError as e:
358
+ left.append((src, dst, f"{dst} -> {src}: {e}"))
359
+ done[:] = [(src, dst) for src, dst, _ in reversed(left)]
360
+ return [why for _, _, why in left]
361
+
362
+
363
+ def _rename_group(parent: str, g: list[dict], src_key: str, dst_key: str, done: list) -> None:
364
+ """One parent directory's renames, which land together or not at all: an error puts every step so far back
365
+ and re-raises with `rollback_failed` on it.
366
+
367
+ A group whose targets include its own sources (a swap, a cycle, a chain) goes through temporary names, in
368
+ two passes, and the signals `lock.deferring` holds back wait for both of them (_roll_back waits for the
369
+ rollback as well). So no file is left at a `.staging-` name: such a name is in no journal and in no
370
+ listing, and the next `pp batch undo` would call the file gone. The deferral is one group long, a few
371
+ renames. A group that renames straight through is not deferred: each of its renames is whole on its own,
372
+ so a Ctrl-C between two of them ends the run where it stands."""
373
+ srcs = {e[src_key] for e in g}
374
+ through_temp = any(e[dst_key] in srcs for e in g)
375
+ with lock.deferring() if through_temp else nullcontext():
376
+ try:
377
+ if through_temp:
378
+ tmp = []
379
+ for e in g:
380
+ t = paths.staging(parent, "batch") # a name no other run of pp can be using
381
+ _mv(os.path.join(parent, e[src_key]), t, done)
382
+ tmp.append((t, e[dst_key]))
383
+ for t, dst in tmp:
384
+ _mv(t, os.path.join(parent, dst), done)
385
+ else:
386
+ for e in g:
387
+ _mv(os.path.join(parent, e[src_key]), os.path.join(parent, e[dst_key]), done)
388
+ except OSError as err:
389
+ err.rollback_failed = _roll_back(done)
390
+ raise
391
+
392
+
393
+ def apply_groups(entries: list[dict], src_key: str, dst_key: str, deepest_first: bool,
394
+ done: list | None = None) -> list:
395
+ """Rename entries ({parent, <src_key>, <dst_key>, depth}) one group of a parent dir at a time
396
+ (_rename_group). Deepest groups first going forward, so a folder is renamed after its contents (their
397
+ recorded parent path is still valid); shallowest first when undoing.
398
+ Returns the (src, dst) steps done; on error rolls them back and re-raises.
399
+
400
+ done is a list of the caller's own to append the steps to, so a caller that is interrupted part way still
401
+ has the ones that landed, and only those: a step that went back comes off the list again. The same list is
402
+ the return value."""
403
+ groups: dict = {}
404
+ for e in entries:
405
+ groups.setdefault(e["parent"], []).append(e)
406
+ order = sorted(groups, key=lambda p: (-groups[p][0]["depth"] if deepest_first else groups[p][0]["depth"], p))
407
+ done = [] if done is None else done
408
+ for parent in order:
409
+ _rename_group(parent, groups[parent], src_key, dst_key, done)
410
+ return done
411
+
412
+
413
+ def _journal_dir() -> str:
414
+ return paths.state_dir("batch", "journal")
415
+
416
+
417
+ def _journal_lock() -> str:
418
+ """One lock for the journals: an undo takes a journal and records what it did without another undo of the
419
+ same one in between."""
420
+ return os.path.join(paths.state_dir("batch"), ".lock")
421
+
422
+
423
+ def _write_journal(path: str, data: dict):
424
+ """A journal of a rename in flight, which only the run that created it writes (undo goes through
425
+ _closing)."""
426
+ paths.write_json(path, data, indent=1)
427
+
428
+
429
+ def _undoing():
430
+ """The journals' lock, with signals left as they are: the renames of an undo go here, so a Ctrl-C ends them
431
+ where it lands, between two groups (_rename_group holds it back inside one). _closing takes the same lock
432
+ inside it, since the thread holding it may take it again."""
433
+ return lock.locked(_journal_lock())
434
+
435
+
436
+ def _closing(path: str):
437
+ """A journal read, changed and written back under the journals' lock. Signals arriving in this scope are
438
+ delivered when it ends, so what the undo did and the journal saying so both land, and what the undo says
439
+ it did is printed in the scope. The renames themselves are not in it, since Ctrl-C waits for it."""
440
+ return paths.json_txn(path, lock=_journal_lock(), indent=1)
441
+
442
+
443
+ def _journal(path: str) -> dict:
444
+ """One journal as it was last written; {} when there is none to read."""
445
+ try:
446
+ with open(path) as f:
447
+ j = json.load(f)
448
+ except (OSError, json.JSONDecodeError):
449
+ return {}
450
+ return j if isinstance(j, dict) else {}
451
+
452
+
453
+ def _journals() -> list[tuple[str, dict]]:
454
+ out = []
455
+ d = _journal_dir()
456
+ for fn in sorted(os.listdir(d), reverse=True):
457
+ if fn.endswith(".json"):
458
+ j = _journal(os.path.join(d, fn))
459
+ if j:
460
+ out.append((os.path.join(d, fn), j))
461
+ return out
462
+
463
+
464
+ def _refuse(problems: list[str], hint: str = "") -> int:
465
+ sys.stdout.flush() # the plan first, then why it is refused (stderr is unbuffered, stdout may not be)
466
+ print(ui.c(f"refusing, {len(problems)} problem(s):", "red"), file=sys.stderr)
467
+ for p in problems:
468
+ print(f" {_shown(p)}", file=sys.stderr)
469
+ if hint:
470
+ print(hint, file=sys.stderr)
471
+ return 2
472
+
473
+
474
+ def _proceed(a, prompt: str) -> int | None:
475
+ """None to go ahead; else the exit code (0 for -n, 2 for a refusal)."""
476
+ if a.dry_run:
477
+ print(ui.c("(dry run: nothing changed)", "dim"))
478
+ return 0
479
+ if not ui.confirm(prompt, a.yes):
480
+ return 2
481
+ return None
482
+
483
+
484
+ def cmd_rename(a) -> int:
485
+ if a.template is not None:
486
+ find, replace, args = None, a.template, a.args
487
+ else:
488
+ if len(a.args) < 3:
489
+ ui.die("rename needs FIND REPLACE PATHS... (or -t TEMPLATE PATHS...)", 2)
490
+ find, replace, args = a.args[0], a.args[1], a.args[2:]
491
+ try:
492
+ rx, parts = prepare_rename(find, replace, a.ignore_case)
493
+ items = collect(args, a.recursive, a.hidden, a.type, a.ext)
494
+ if not items:
495
+ ui.die("nothing to rename: no paths matched", 1)
496
+ changes, unchanged, problems = plan_rename(items, rx, parts, a.stem, a.start)
497
+ except TemplateError as e:
498
+ ui.die(_shown(str(e)), 2)
499
+ _print_pairs([(c["path"], c["target"]) for c in changes])
500
+ print(f"{len(changes)} to rename" + (f", {unchanged} unchanged" if unchanged else ""))
501
+ if problems:
502
+ return _refuse(problems)
503
+ if not changes:
504
+ return 0
505
+ rc = _proceed(a, f"rename {len(changes)} item(s)?")
506
+ if rc is not None:
507
+ return rc
508
+ jpath = os.path.join(_journal_dir(), datetime.now().strftime("%Y%m%d-%H%M%S-%f") + ".json")
509
+ entries = [{k: c[k] for k in ("parent", "old", "new", "depth")} for c in changes]
510
+ journal = {"created": time.time(), "cwd": os.getcwd(), "argv": a.argv, "status": "pending", "entries": entries}
511
+ _write_journal(jpath, journal) # before touching anything: a crash mid-way still leaves a record
512
+ try:
513
+ apply_groups(entries, "old", "new", deepest_first=True)
514
+ except OSError as e:
515
+ bad = getattr(e, "rollback_failed", [])
516
+ journal["status"] = "failed" if bad else "rolled-back"
517
+ journal["error"] = str(e)
518
+ _write_journal(jpath, journal)
519
+ print(ui.c(f"rename failed: {_shown(str(e))}", "red"), file=sys.stderr)
520
+ if bad:
521
+ print(f"rollback incomplete; fix by hand ({jpath}):", file=sys.stderr)
522
+ for b in bad:
523
+ print(f" {_shown(b)}", file=sys.stderr)
524
+ else:
525
+ print("rolled back: nothing changed", file=sys.stderr)
526
+ return 1
527
+ journal["status"] = "applied"
528
+ _write_journal(jpath, journal)
529
+ print(f"renamed {len(changes)} item(s); undo with: pp batch undo")
530
+ return 0
531
+
532
+
533
+ # ---- undo -----------------------------------------------------------------------------------------
534
+
535
+ def _now(p: str, ren: dict) -> str:
536
+ """Where an original path lives after the journal's renames: its ancestors may have been renamed too."""
537
+ parent, name = os.path.split(p)
538
+ if not name:
539
+ return p
540
+ return os.path.join(_now(parent, ren), ren.get(p, name))
541
+
542
+
543
+ def _rel(p: str) -> str:
544
+ r = os.path.relpath(p)
545
+ return p if r.startswith("..") else r
546
+
547
+
548
+ def cmd_undo(a) -> int:
549
+ js = _journals()
550
+ if a.list:
551
+ rows = []
552
+ for path, j in js[:a.n]:
553
+ e = j.get("entries", [])
554
+ ex = f"{_shown(e[0]['old'])} -> {_shown(e[0]['new'])}" + (" ..." if len(e) > 1 else "") if e else ""
555
+ status = j.get("status", "") + (f", {len(j['left'])} left" if j.get("left") else "")
556
+ rows.append([os.path.basename(path)[:-5], ui.human_age(j.get("created")), status, len(e), ex])
557
+ if a.json:
558
+ ui.print_json([{"id": os.path.basename(p)[:-5], **j} for p, j in js[:a.n]])
559
+ elif rows:
560
+ print(ui.table(rows, ["journal", "when", "status", "items", "example"]))
561
+ else:
562
+ print("no rename journals yet")
563
+ return 0
564
+ target = next(((p, j) for p, j in js if j.get("status") == "applied"), None)
565
+ if not target:
566
+ ui.die("nothing to undo (no applied rename journal)", 1)
567
+ jpath, journal = target
568
+ entries = journal["entries"]
569
+ ren = {os.path.join(e["parent"], e["old"]): os.path.join(e["parent"], e["new"]) for e in entries}
570
+ ren_names = {k: os.path.basename(v) for k, v in ren.items()}
571
+ problems, pairs, stuck = [], [], []
572
+ news_by_parent: dict = {}
573
+ for e in entries:
574
+ news_by_parent.setdefault(e["parent"], set()).add(e["new"])
575
+ for e in entries:
576
+ here = os.path.join(_now(e["parent"], ren_names), e["new"])
577
+ back = os.path.join(_now(e["parent"], ren_names), e["old"])
578
+ if not os.path.lexists(here):
579
+ problems.append(f"{_rel(here)} is gone (moved or deleted since the rename)")
580
+ elif os.path.lexists(back) and e["old"] not in news_by_parent[e["parent"]]:
581
+ problems.append(f"{_rel(back)} exists now, so {_rel(here)} cannot go back")
582
+ else:
583
+ pairs.append((_rel(here), _rel(os.path.join(e["parent"], e["old"]))))
584
+ continue
585
+ stuck.append(os.path.join(e["parent"], e["old"]))
586
+ if problems and a.partial:
587
+ # what is inside a folder that stays renamed stays too: its recorded parent will not exist
588
+ inside = lambda e: any(e["parent"] == d or e["parent"].startswith(d + os.sep) for d in stuck)
589
+ keep = [e for e in entries if os.path.join(e["parent"], e["old"]) not in stuck and not inside(e)]
590
+ pairs = [(_rel(os.path.join(_now(e["parent"], ren_names), e["new"])), _rel(os.path.join(e["parent"], e["old"])))
591
+ for e in keep]
592
+ else:
593
+ keep = entries
594
+ print(f"undo {os.path.basename(jpath)[:-5]} ({ui.human_age(journal.get('created'))}, {len(entries)} item(s)):")
595
+ _print_pairs(pairs)
596
+ if problems and not a.partial:
597
+ return _refuse(problems, "pp batch undo --partial restores the rest and leaves these where they are")
598
+ if problems:
599
+ print(f"leaving {len(entries) - len(keep)} where they are:")
600
+ for p in problems:
601
+ print(f" {_shown(p)}")
602
+ rc = _proceed(a, f"undo {len(keep)} rename(s)?" if keep else
603
+ "nothing in it can be restored: close this journal, so undo moves on to the one before?")
604
+ if rc is not None:
605
+ return rc
606
+ with _undoing():
607
+ if _journal(jpath).get("status") != "applied":
608
+ # another `pp batch undo` took this journal while this one was asking; it has had its renames
609
+ ui.die(f"{os.path.basename(jpath)[:-5]} was undone while this asked; `pp batch undo` again", 1)
610
+ done: list = []
611
+ try:
612
+ apply_groups(keep, "new", "old", deepest_first=False, done=done)
613
+ except OSError as e:
614
+ bad = getattr(e, "rollback_failed", [])
615
+ print(ui.c(f"undo failed: {_shown(str(e))}", "red") + ("" if bad else "; rolled back, nothing changed"),
616
+ file=sys.stderr)
617
+ for b in bad:
618
+ print(f" rollback incomplete: {_shown(b)}", file=sys.stderr)
619
+ raise SystemExit(1) # the journal keeps what it said: nothing was restored
620
+ except BaseException:
621
+ # a Ctrl-C ends the renames between groups. The journal keeps the ones still in place and the
622
+ # files that are back are off it, so the command named here restores the rest and asks about
623
+ # nothing else; a journal with nothing left on it is undone.
624
+ back = [e for e in keep if os.path.join(e["parent"], e["old"]) in {d for _, d in done}]
625
+ with _closing(jpath) as journal:
626
+ rest = [e for e in journal["entries"] if e not in back]
627
+ journal["entries"] = rest
628
+ journal["restored"] = journal.get("restored", []) + back
629
+ if rest:
630
+ again = "`pp batch undo --partial`" if problems else "`pp batch undo`"
631
+ print(f"stopped with {len(back)} of {len(keep)} item(s) back; {again} restores the rest",
632
+ file=sys.stderr)
633
+ else:
634
+ journal["status"] = "undone"
635
+ journal["undone"] = time.time()
636
+ print(f"restored {len(back)} item(s)")
637
+ raise
638
+ with _closing(jpath) as journal:
639
+ journal["status"] = "undone"
640
+ journal["undone"] = time.time()
641
+ if len(keep) < len(entries):
642
+ journal["left"] = [e for e in entries if e not in keep]
643
+ print(f"restored {len(keep)} item(s)"
644
+ + (f"; {len(entries) - len(keep)} left as they are" if len(keep) < len(entries) else ""))
645
+ return 0
646
+
647
+
648
+ # ---- run ------------------------------------------------------------------------------------------
649
+
650
+ def _presets() -> dict[str, dict]:
651
+ table = config.load("batch")
652
+ config.check_keys(table, f"{config.global_path()} [batch]", ("presets",))
653
+ out = {}
654
+ for name, v in (table.get("presets") or {}).items():
655
+ if isinstance(v, str):
656
+ out[name] = {"cmd": v}
657
+ elif isinstance(v, dict) and isinstance(v.get("cmd"), str) and set(v) <= {"cmd", "out"}:
658
+ out[name] = {"cmd": v["cmd"], **({"out": v["out"]} if v.get("out") else {})}
659
+ else:
660
+ raise config.ConfigError(f"{config.global_path()} [batch.presets] {name}: a command line, or "
661
+ "{ cmd = \"...\", out = \"...\" }")
662
+ return out
663
+
664
+
665
+ def cmd_presets(a) -> int:
666
+ ps = _presets()
667
+ if a.json:
668
+ ui.print_json(ps)
669
+ elif ps:
670
+ print(ui.table([[_shown(f"@{n}"), _shown(p["cmd"]), _shown(p.get("out", ""))] for n, p in sorted(ps.items())],
671
+ ["preset", "cmd", "out"]))
672
+ else:
673
+ print(f"no presets. Add some to {config.global_path()}:\n\n[batch.presets]\n"
674
+ 'webp = { cmd = "cwebp -q 85 {} -o {out}", out = "{base}.webp" }\nlines = "wc -l {}"')
675
+ return 0
676
+
677
+
678
+ def cmd_run(a) -> int:
679
+ tmpl, out_t = a.template, a.out
680
+ if tmpl.startswith("@"):
681
+ ps = _presets()
682
+ if tmpl[1:] not in ps:
683
+ have = ', '.join('@' + _shown(n) for n in sorted(ps)) or 'none (pp batch presets)'
684
+ ui.die(f"no preset {tmpl!r}; have: {have}", 2)
685
+ tmpl, out_t = ps[tmpl[1:]]["cmd"], out_t or ps[tmpl[1:]].get("out")
686
+ try:
687
+ parts = compile_template(tmpl, FIELDS | ({"out"} if out_t else set()))
688
+ out_parts = compile_template(out_t, FIELDS) if out_t else None
689
+ items = collect(a.paths, a.recursive, a.hidden, a.type, a.ext)
690
+ if not items:
691
+ ui.die("nothing to run on: no paths matched", 1)
692
+ jobs, skipped = [], []
693
+ for i, (path, k) in enumerate(zip(items, _positions(items, a.start))):
694
+ f = _fields(path, i + a.start, len(items), k)
695
+ out = None
696
+ if out_parts:
697
+ out = f["out"] = render(out_parts, f)
698
+ if os.path.lexists(out) and not a.overwrite:
699
+ skipped.append(path)
700
+ continue
701
+ jobs.append((path, render(parts, f, quote=True), out))
702
+ except TemplateError as e:
703
+ ui.die(_shown(str(e)), 2)
704
+ if not any(isinstance(p, tuple) for p in parts) and len(jobs) > 1:
705
+ ui.warn(f"the command uses no {{field}}: the same command would run {len(jobs)} times")
706
+ for _, cmd, _ in jobs[:10]:
707
+ print(f" $ {_shown(cmd)}")
708
+ if len(jobs) > 10:
709
+ print(f" ... {len(jobs) - 10} more")
710
+ print(f"{len(jobs)} command(s) to run" + (f", {len(skipped)} skipped (output exists; --overwrite to redo)" if skipped else ""))
711
+ by_out: dict = {}
712
+ for path, _, out in jobs:
713
+ if out:
714
+ by_out.setdefault(out, []).append(path)
715
+ problems = [f"{', '.join(ps)} would all write {out}" for out, ps in by_out.items() if len(ps) > 1]
716
+ if problems:
717
+ # with -j above 1 both jobs find the path missing and count it as theirs, so the one that finishes last
718
+ # wins and a failing one removes what a finished one wrote. rename refuses two items over one name.
719
+ return _refuse(problems, "give --out a template that differs per item, such as one with {stem} or {n}")
720
+ if not jobs:
721
+ return 0
722
+ rc = _proceed(a, f"run {len(jobs)} command(s)" + (f", {a.jobs} at a time" if a.jobs > 1 else "") + "?")
723
+ if rc is not None:
724
+ return rc
725
+ counts = {"ok": 0, "failed": 0, "not run": 0}
726
+ stop, lock = threading.Event(), threading.Lock()
727
+
728
+ def one(job):
729
+ path, cmd, out = job
730
+ if stop.is_set():
731
+ with lock:
732
+ counts["not run"] += 1
733
+ return
734
+ fresh = bool(out) and not os.path.lexists(out)
735
+ try:
736
+ if out and os.path.dirname(out):
737
+ os.makedirs(os.path.dirname(out), exist_ok=True)
738
+ # pp's own group: a Ctrl-C in this terminal reaches the command too, as it would from a shell
739
+ p = proc.run(["/bin/sh", "-c", cmd], group="shared", stdin="none", env="inherit", stderr=proc.STDOUT)
740
+ rc_, output = p.returncode, p.stdout
741
+ except OSError as e:
742
+ rc_, output = None, str(e)
743
+ ok = rc_ == 0
744
+ # a failed command's half-written output would pass for "already converted" next time and be skipped.
745
+ # No two jobs render one {out} (the plan refuses that), so an output that was not there when this job
746
+ # started is this job's own.
747
+ gone, left = False, ""
748
+ if not ok and fresh and os.path.isfile(out) and not os.path.islink(out):
749
+ try:
750
+ os.remove(out)
751
+ gone = True
752
+ except OSError as e: # said with this item, so the run still reports every other one
753
+ left = _shown(f" its partial output {out} is still there: {e}")
754
+ with lock:
755
+ counts["ok" if ok else "failed"] += 1
756
+ print(f"{ui.c('ok', 'green')} {_shown(path)}" if ok else
757
+ f"{ui.c('FAILED', 'red')} rc={rc_} {_shown(path)}\n $ {_shown(cmd)}")
758
+ if output.strip() and (not ok or not a.quiet):
759
+ print("\n".join(" " + _shown(ln) for ln in output.rstrip("\n").splitlines()))
760
+ if gone:
761
+ print(f" removed its partial output {_shown(out)}")
762
+ if left:
763
+ print(left)
764
+ sys.stdout.flush()
765
+ if not ok and not a.keep_going:
766
+ stop.set()
767
+
768
+ if a.jobs > 1:
769
+ with ThreadPoolExecutor(a.jobs) as ex:
770
+ list(ex.map(one, jobs))
771
+ else:
772
+ for job in jobs:
773
+ one(job)
774
+ summary = f"{counts['ok']} ok, {counts['failed']} failed"
775
+ summary += f", {counts['not run']} not run (stopped at the first failure; --keep-going to continue)" if counts["not run"] else ""
776
+ summary += f", {len(skipped)} skipped" if skipped else ""
777
+ print(ui.c(summary, "red" if counts["failed"] else "green"))
778
+ return 1 if counts["failed"] else 0
779
+
780
+
781
+ # ---- cli ------------------------------------------------------------------------------------------
782
+
783
+ VERBS = {"rename": "rename by regex or template", "run": "run a command per item",
784
+ "undo": "reverse the last applied rename", "presets": "list run presets from config"}
785
+
786
+
787
+ def _verb_parser(verb: str):
788
+ usage = ("%(prog)s [options] FIND REPLACE PATHS... or %(prog)s [options] -t TEMPLATE PATHS..."
789
+ if verb == "rename" else None)
790
+ p = ui.parser(f"batch {verb}", __doc__ if verb in ("rename", "run") else VERBS[verb], usage=usage)
791
+
792
+ def items_opts():
793
+ p.add_argument("-r", "--recursive", action="store_true", help="folders mean everything inside them")
794
+ p.add_argument("--hidden", action="store_true", help="include dot-files when expanding globs/folders")
795
+ p.add_argument("--type", choices=("f", "d"), help="only files (f) or only folders (d)")
796
+ p.add_argument("--ext", help="only these extensions, e.g. jpg,png")
797
+ p.add_argument("--start", type=int, default=1, help="first value of {n} and {i} (default 1)")
798
+ p.add_argument("-n", "--dry-run", action="store_true", help="show the plan, change nothing")
799
+ p.add_argument("-y", "--yes", action="store_true", help="do not ask")
800
+
801
+ if verb == "rename":
802
+ p.add_argument("args", nargs="+", metavar="FIND REPLACE PATHS", help="or, with -t: PATHS")
803
+ p.add_argument("-t", "--template", help="the whole new name, e.g. '{parent}_{i:03}{ext}' "
804
+ "(one starting with -: --template=-...)")
805
+ p.add_argument("-i", "--ignore-case", action="store_true")
806
+ p.add_argument("--stem", action="store_true", help="work on the name without its extension; keep the extension")
807
+ items_opts()
808
+ elif verb == "run":
809
+ p.add_argument("template", help="command template, or @preset")
810
+ p.add_argument("paths", nargs="+")
811
+ p.add_argument("-j", "--jobs", type=int, default=1, help="run N at a time")
812
+ p.add_argument("--out", help="output path template; defines {out}; existing outputs are skipped, "
813
+ "and a failed command's new output is removed")
814
+ p.add_argument("--overwrite", action="store_true", help="run even when {out} exists")
815
+ p.add_argument("--keep-going", action="store_true", help="do not stop at the first failure")
816
+ p.add_argument("-q", "--quiet", action="store_true", help="hide output of commands that succeed")
817
+ items_opts()
818
+ elif verb == "undo":
819
+ p.add_argument("--list", action="store_true", help="show recent rename journals")
820
+ p.add_argument("--partial", action="store_true",
821
+ help="restore what still can be; leave items that are gone or blocked where they are")
822
+ p.add_argument("--json", action="store_true")
823
+ p.add_argument("-N", dest="n", type=int, default=10, help="how many journals --list shows")
824
+ p.add_argument("-n", "--dry-run", action="store_true")
825
+ p.add_argument("-y", "--yes", action="store_true")
826
+ else:
827
+ p.add_argument("--json", action="store_true")
828
+ return p
829
+
830
+
831
+ def main(argv: list[str]) -> int:
832
+ # Verbs are dispatched by hand rather than with subparsers so each can use an intermixed parse:
833
+ # `pp batch rename ' ' _ -r .` puts an option between positionals, which plain argparse rejects.
834
+ if not argv or argv[0] in ("-h", "--help"):
835
+ ui.parser("batch", __doc__).print_help()
836
+ print("\nverbs: " + " ".join(f"{v} ({h})" for v, h in VERBS.items()))
837
+ return 0
838
+ verb = argv[0]
839
+ if verb not in VERBS:
840
+ cands = [v for v in VERBS if v.startswith(verb)]
841
+ if len(cands) > 1:
842
+ ui.die(f"{verb!r} is ambiguous: {', '.join(cands)}", 2)
843
+ if not cands:
844
+ ui.die(f"unknown verb {verb!r}; one of: {', '.join(VERBS)}", 2)
845
+ verb = cands[0]
846
+ a = ui.parse_intermixed(_verb_parser(verb), argv[1:])
847
+ a.argv = argv
848
+ return {"rename": cmd_rename, "run": cmd_run, "undo": cmd_undo, "presets": cmd_presets}[verb](a)