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/__init__.py +4 -0
- pp/__main__.py +5 -0
- pp/cli.py +363 -0
- pp/commands/__init__.py +1 -0
- pp/commands/batch.py +848 -0
- pp/commands/cmd.py +545 -0
- pp/commands/completion.py +55 -0
- pp/commands/http.py +1499 -0
- pp/commands/kill.py +293 -0
- pp/commands/promote.py +945 -0
- pp/commands/queue.py +243 -0
- pp/lib/__init__.py +1 -0
- pp/lib/config.py +110 -0
- pp/lib/jobq.py +3106 -0
- pp/lib/lock.py +338 -0
- pp/lib/paths.py +163 -0
- pp/lib/proc.py +706 -0
- pp/lib/session.py +31 -0
- pp/lib/ui.py +189 -0
- ppkit-0.1.0.dist-info/METADATA +330 -0
- ppkit-0.1.0.dist-info/RECORD +24 -0
- ppkit-0.1.0.dist-info/WHEEL +4 -0
- ppkit-0.1.0.dist-info/entry_points.txt +3 -0
- ppkit-0.1.0.dist-info/licenses/LICENSE +202 -0
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)
|