@codebend3r/che 1.0.0 → 1.2.0
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.
- package/README.md +65 -25
- package/bin/art.py +123 -0
- package/bin/che.py +209 -568
- package/bin/commands.py +220 -231
- package/bin/doctor.py +220 -0
- package/bin/drives/eject-all-drives.py +21 -38
- package/bin/drives/mount-all-drives.py +18 -62
- package/bin/drives/ping-nas.py +77 -105
- package/bin/files/compress-folders.py +7 -11
- package/bin/files/delete-by-ext.py +10 -14
- package/bin/files/delete-empty-folders.py +33 -37
- package/bin/files/delete-smb-files.py +12 -21
- package/bin/files/files-under-size.py +5 -11
- package/bin/files/find-largest-files.py +21 -25
- package/bin/files/list-permission.py +40 -0
- package/bin/files/make-alpha-dir.py +2 -0
- package/bin/git/all-actions.py +350 -253
- package/bin/git/checkout-my-branches.py +42 -63
- package/bin/git/clean-stale-branches.py +23 -48
- package/bin/git/prune-worktrees.py +12 -51
- package/bin/git/sync-all-branches.py +54 -283
- package/bin/git/update-local-branches.py +67 -73
- package/bin/gitutils.py +263 -0
- package/bin/install.py +393 -1188
- package/bin/machine.py +345 -0
- package/bin/media.py +127 -0
- package/bin/menu.py +437 -0
- package/bin/nas.py +102 -0
- package/bin/rcfile.py +380 -0
- package/bin/shellgen.py +508 -428
- package/bin/system/btop-launch.py +17 -13
- package/bin/system/update-brew.py +57 -68
- package/bin/tui.py +156 -153
- package/bin/utils.py +165 -237
- package/bin/video/delete-duplicate-videos.py +64 -62
- package/bin/video/detect-green-magenta-videos.py +105 -95
- package/bin/video/find-movie-by-year.py +3 -0
- package/bin/video/find-video-mkv-issues.py +48 -65
- package/bin/video/fix-codecs.py +25 -30
- package/bin/video/largest-tv-shows.py +60 -57
- package/bin/video/remove-metadata.py +3 -6
- package/bin/video/rename-video-file.py +74 -84
- package/bin/video/scan-videos-audio-language.py +16 -33
- package/bin/video/show-codecs.py +20 -24
- package/bin/video/validate-video-files.py +5 -8
- package/bin/video/video-list.py +17 -20
- package/package.json +4 -10
- package/shell/che.bash +33 -33
- package/shell/che.fish +7 -7
- package/shell/che.zsh +33 -33
- package/shell/completions/_che +19 -19
- package/shell/completions/che.bash +15 -15
- package/shell/completions/che.fish +34 -50
package/bin/utils.py
CHANGED
|
@@ -13,6 +13,10 @@ Everything here is standard library only, on purpose: the scripts have to run
|
|
|
13
13
|
from a bare ``python3`` on any machine this repo is cloned to, with no venv and
|
|
14
14
|
no install step.
|
|
15
15
|
|
|
16
|
+
Domain helpers live beside it: git and worktree helpers in ``gitutils.py``,
|
|
17
|
+
ffprobe helpers in ``media.py``, the NAS drive table and SMB mounting in
|
|
18
|
+
``nas.py``.
|
|
19
|
+
|
|
16
20
|
=============================================================================
|
|
17
21
|
CLI conventions - shared patterns for bin/<category>/*.py
|
|
18
22
|
|
|
@@ -30,12 +34,12 @@ CLI conventions - shared patterns for bin/<category>/*.py
|
|
|
30
34
|
from __future__ import annotations
|
|
31
35
|
|
|
32
36
|
import argparse
|
|
33
|
-
import json
|
|
34
37
|
import os
|
|
35
38
|
import re
|
|
36
39
|
import shutil
|
|
37
40
|
import subprocess
|
|
38
41
|
import sys
|
|
42
|
+
import tempfile
|
|
39
43
|
from collections.abc import Callable, Iterable, Iterator, Sequence
|
|
40
44
|
from fnmatch import fnmatchcase
|
|
41
45
|
from pathlib import Path
|
|
@@ -52,12 +56,13 @@ YELLOW = "\033[1;33m"
|
|
|
52
56
|
CYAN = "\033[1;36m"
|
|
53
57
|
MAGENTA = "\033[1;35m"
|
|
54
58
|
|
|
55
|
-
_FORCE_COLOR_OFF = {"", "0", "false", "no", "off"}
|
|
56
|
-
|
|
57
59
|
|
|
58
|
-
def
|
|
60
|
+
def color_enabled() -> bool:
|
|
59
61
|
"""Honor NO_COLOR and non-tty output the way a well-behaved CLI should.
|
|
60
62
|
|
|
63
|
+
The log helpers and :func:`paint` ask this, and so must any caller that
|
|
64
|
+
builds its own escape sequences (per-language colouring, table headers).
|
|
65
|
+
|
|
61
66
|
DELIBERATE DIVERGENCE from ``utils.sh``, which always emitted escapes.
|
|
62
67
|
Suppressing them when stdout is not a terminal keeps captured output clean
|
|
63
68
|
for the tests and for shell pipelines. ``FORCE_COLOR`` overrides, following
|
|
@@ -66,8 +71,8 @@ def _color_enabled() -> bool:
|
|
|
66
71
|
The tty check happens at call time, not import time, so
|
|
67
72
|
``contextlib.redirect_stdout`` and pytest's capture are both respected.
|
|
68
73
|
"""
|
|
69
|
-
force = os.environ.get("FORCE_COLOR")
|
|
70
|
-
if force
|
|
74
|
+
force = os.environ.get("FORCE_COLOR", "").strip().lower()
|
|
75
|
+
if force and force not in _FALSE_VALUES:
|
|
71
76
|
return True
|
|
72
77
|
if os.environ.get("NO_COLOR"):
|
|
73
78
|
return False
|
|
@@ -77,11 +82,14 @@ def _color_enabled() -> bool:
|
|
|
77
82
|
return False
|
|
78
83
|
|
|
79
84
|
|
|
80
|
-
def
|
|
81
|
-
"""
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
+
def paint(text: str, color: str) -> str:
|
|
86
|
+
"""``text`` wrapped in ``color``, or bare when colour is off.
|
|
87
|
+
|
|
88
|
+
For output assembled before it is printed - a table cell, one coloured word
|
|
89
|
+
in a plain line. An ``f"{RED}...{NC}"`` built by hand ignores ``NO_COLOR``
|
|
90
|
+
and leaks escapes into piped output.
|
|
91
|
+
"""
|
|
92
|
+
return f"{color}{text}{NC}" if color_enabled() else text
|
|
85
93
|
|
|
86
94
|
|
|
87
95
|
def log_color(color: str, message: str) -> None:
|
|
@@ -97,10 +105,7 @@ def log_color(color: str, message: str) -> None:
|
|
|
97
105
|
error messages appear *before* the banner that introduced them whenever the
|
|
98
106
|
output is piped to a file or a CI log.
|
|
99
107
|
"""
|
|
100
|
-
|
|
101
|
-
print(f"{color}{message}{NC}", flush=True)
|
|
102
|
-
else:
|
|
103
|
-
print(message, flush=True)
|
|
108
|
+
print(paint(message, color), flush=True)
|
|
104
109
|
|
|
105
110
|
|
|
106
111
|
def log(message: str) -> None:
|
|
@@ -128,6 +133,49 @@ def success(message: str) -> None:
|
|
|
128
133
|
log_color(MAGENTA, message)
|
|
129
134
|
|
|
130
135
|
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
# Banners
|
|
138
|
+
#
|
|
139
|
+
# The fixed shapes scripts frame their output with, so the widths and the
|
|
140
|
+
# dry-run marker stay identical from one script to the next.
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
_HEADING_RULE = "═" * 43
|
|
144
|
+
_SETTINGS_RULE = "-" * 52
|
|
145
|
+
_FOOTER_RULE = "─" * 45
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def heading(title: str, *, dry_run: bool = False) -> None:
|
|
149
|
+
"""The three-line banner a script opens with, marked when it only previews."""
|
|
150
|
+
note(_HEADING_RULE)
|
|
151
|
+
note(f"{title}{' 🌵 (dry run)' if dry_run else ''}")
|
|
152
|
+
note(_HEADING_RULE)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def note_settings(*rows: tuple[str, object], rule: bool = True) -> None:
|
|
156
|
+
"""Echo a script's resolved settings as ``Label: value`` lines.
|
|
157
|
+
|
|
158
|
+
Booleans print lowercase, ``true``/``false``, as the shell versions echoed
|
|
159
|
+
them; anything else prints as-is. ``rule`` closes the block with the dashed
|
|
160
|
+
separator that sets the settings apart from the results.
|
|
161
|
+
"""
|
|
162
|
+
for label, value in rows:
|
|
163
|
+
note(f"{label}: {str(value).lower() if isinstance(value, bool) else value}")
|
|
164
|
+
if rule:
|
|
165
|
+
note(_SETTINGS_RULE)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def footer_rule() -> None:
|
|
169
|
+
"""A blank line and a thin rule, opening a script's closing tally."""
|
|
170
|
+
print()
|
|
171
|
+
note(_FOOTER_RULE)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def dry_run_complete(verb: str = "changed") -> None:
|
|
175
|
+
"""The closing line of a dry run: ``Nothing was actually <verb>.``"""
|
|
176
|
+
log(f"💡 Dry run complete. Nothing was actually {verb}.")
|
|
177
|
+
|
|
178
|
+
|
|
131
179
|
# ---------------------------------------------------------------------------
|
|
132
180
|
# Formatting
|
|
133
181
|
# ---------------------------------------------------------------------------
|
|
@@ -183,7 +231,7 @@ _FALSE_VALUES = {"false", "no", "0", "off"}
|
|
|
183
231
|
_FALSE_SUFFIX = "-set-false-internal"
|
|
184
232
|
|
|
185
233
|
|
|
186
|
-
def parse_bool(value: str
|
|
234
|
+
def parse_bool(value: str) -> bool:
|
|
187
235
|
"""Parse a boolean spelling.
|
|
188
236
|
|
|
189
237
|
Accepts ``true|yes|1|on`` and ``false|no|0|off``, case-insensitive, and
|
|
@@ -195,9 +243,6 @@ def parse_bool(value: str | bool) -> bool:
|
|
|
195
243
|
There ``true``/``false`` worked, ``1``/``0``/``on``/``off``/``no`` were
|
|
196
244
|
"command not found", and ``yes`` invoked ``/usr/bin/yes`` and hung forever.
|
|
197
245
|
"""
|
|
198
|
-
if isinstance(value, bool):
|
|
199
|
-
return value
|
|
200
|
-
|
|
201
246
|
lowered = value.strip().lower()
|
|
202
247
|
if lowered in _TRUE_VALUES:
|
|
203
248
|
return True
|
|
@@ -250,10 +295,8 @@ class BoolFlagParser(argparse.ArgumentParser):
|
|
|
250
295
|
rewritten.append(flag if enabled else self._bool_flags[flag])
|
|
251
296
|
return rewritten
|
|
252
297
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
return super().parse_args(self._rewrite(argv), namespace)
|
|
256
|
-
|
|
298
|
+
# parse_args delegates to parse_known_args, so this one override rewrites
|
|
299
|
+
# argv for both - and only once.
|
|
257
300
|
def parse_known_args(self, args=None, namespace=None): # type: ignore[override]
|
|
258
301
|
argv = list(sys.argv[1:] if args is None else args)
|
|
259
302
|
return super().parse_known_args(self._rewrite(argv), namespace)
|
|
@@ -343,6 +386,28 @@ def env_bool(name: str, default: bool) -> bool:
|
|
|
343
386
|
sys.exit(1)
|
|
344
387
|
|
|
345
388
|
|
|
389
|
+
_COUNT_RE = re.compile(r"[0-9]+")
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def parse_count(raw: str, flag: str, *, minimum: int = 0) -> int:
|
|
393
|
+
"""Validate an integer flag, or report why it is invalid and exit 1.
|
|
394
|
+
|
|
395
|
+
Integer flags are declared as plain strings and validated here, not with
|
|
396
|
+
``type=int``. argparse's own conversion would own the failure and exit
|
|
397
|
+
before the shell version's message could print, and ``int()`` accepts
|
|
398
|
+
``+5``, ``5_0`` and surrounding whitespace, all of which the shell's
|
|
399
|
+
``^[0-9]+$`` test rejected. A leading zero is decimal: ``010`` is 10.
|
|
400
|
+
|
|
401
|
+
``minimum`` of 1 or more reads "positive integer" in the message, 0 reads
|
|
402
|
+
"non-negative integer".
|
|
403
|
+
"""
|
|
404
|
+
if not _COUNT_RE.fullmatch(raw) or int(raw) < minimum:
|
|
405
|
+
kind = "positive" if minimum > 0 else "non-negative"
|
|
406
|
+
warning(f"❌ {flag} must be a {kind} integer (got: {raw})")
|
|
407
|
+
sys.exit(1)
|
|
408
|
+
return int(raw)
|
|
409
|
+
|
|
410
|
+
|
|
346
411
|
# ---------------------------------------------------------------------------
|
|
347
412
|
# Subprocess
|
|
348
413
|
# ---------------------------------------------------------------------------
|
|
@@ -375,7 +440,6 @@ def run(
|
|
|
375
440
|
check: bool = True,
|
|
376
441
|
capture: bool = False,
|
|
377
442
|
cwd: str | Path | None = None,
|
|
378
|
-
text: bool = True,
|
|
379
443
|
env: dict[str, str] | None = None,
|
|
380
444
|
stdin: int | None = None,
|
|
381
445
|
timeout: float | None = None,
|
|
@@ -398,12 +462,12 @@ def run(
|
|
|
398
462
|
check=check,
|
|
399
463
|
capture_output=capture,
|
|
400
464
|
cwd=cwd,
|
|
401
|
-
text=
|
|
465
|
+
text=True,
|
|
402
466
|
# errors="replace" rather than the default "strict": a single
|
|
403
467
|
# non-UTF-8 byte in a child's diagnostic (a legacy-encoded title tag
|
|
404
468
|
# echoed back by ffmpeg, say) would otherwise raise UnicodeDecodeError
|
|
405
469
|
# and abort a whole library scan partway through.
|
|
406
|
-
errors="replace"
|
|
470
|
+
errors="replace",
|
|
407
471
|
env=merged_env,
|
|
408
472
|
stdin=stdin,
|
|
409
473
|
timeout=timeout,
|
|
@@ -426,20 +490,6 @@ def run_output(argv: Sequence[str], *, check: bool = True, cwd: str | Path | Non
|
|
|
426
490
|
return (completed.stdout or "").rstrip("\n")
|
|
427
491
|
|
|
428
492
|
|
|
429
|
-
def run_lines(
|
|
430
|
-
argv: Sequence[str], *, check: bool = True, cwd: str | Path | None = None
|
|
431
|
-
) -> list[str]:
|
|
432
|
-
"""Run a command and return stdout split into non-empty lines.
|
|
433
|
-
|
|
434
|
-
DELIBERATE DIVERGENCE: in the shell scripts these reads sat inside
|
|
435
|
-
``< <(...)`` process substitution, whose failure does NOT trip ``set -e`` -
|
|
436
|
-
a failing ``git for-each-ref`` just looked like "no branches" and the script
|
|
437
|
-
exited 0. Here a failure raises.
|
|
438
|
-
"""
|
|
439
|
-
output = run_output(argv, check=check, cwd=cwd)
|
|
440
|
-
return [line for line in output.split("\n") if line]
|
|
441
|
-
|
|
442
|
-
|
|
443
493
|
def run_cli(entry: Callable[[], int]) -> None:
|
|
444
494
|
"""Run a script's ``main`` and translate exceptions into shell exit codes.
|
|
445
495
|
|
|
@@ -469,202 +519,20 @@ def run_cli(entry: Callable[[], int]) -> None:
|
|
|
469
519
|
sys.exit(130)
|
|
470
520
|
|
|
471
521
|
|
|
472
|
-
# ---------------------------------------------------------------------------
|
|
473
|
-
# Worktrees
|
|
474
|
-
#
|
|
475
|
-
# A branch checked out in a linked worktree cannot be checked out, deleted or
|
|
476
|
-
# reset from the main clone - git refuses with "already used by worktree".
|
|
477
|
-
# That is a mechanical limit, not a conflict, so every script that touches
|
|
478
|
-
# branches needs to know which ones are pinned and where.
|
|
479
|
-
# ---------------------------------------------------------------------------
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
def worktree_branch_map(cwd: str | Path | None = None) -> dict[str, str]:
|
|
483
|
-
"""Map ``branch -> worktree path`` for every worktree with a branch checked out.
|
|
484
|
-
|
|
485
|
-
Detached worktrees are skipped (they pin no branch). The main worktree IS
|
|
486
|
-
included, because its branch is pinned for exactly the same reason.
|
|
487
|
-
|
|
488
|
-
DELIBERATE DIVERGENCE: ``utils.sh`` emitted "<branch>\t<path>" lines that
|
|
489
|
-
every caller re-parsed. A dict is the same data without the round trip, and
|
|
490
|
-
it cannot be truncated by a path containing a tab.
|
|
491
|
-
"""
|
|
492
|
-
mapping: dict[str, str] = {}
|
|
493
|
-
path = ""
|
|
494
|
-
branch = ""
|
|
495
|
-
|
|
496
|
-
def flush() -> None:
|
|
497
|
-
if path and branch:
|
|
498
|
-
# First writer wins: a branch can only be pinned by one worktree,
|
|
499
|
-
# so a duplicate would mean git contradicted itself.
|
|
500
|
-
mapping.setdefault(branch, path)
|
|
501
|
-
|
|
502
|
-
for line in run_output(["git", "worktree", "list", "--porcelain"], cwd=cwd).splitlines():
|
|
503
|
-
if line.startswith("worktree "):
|
|
504
|
-
# A new record starts; flush the previous one.
|
|
505
|
-
flush()
|
|
506
|
-
path = line.removeprefix("worktree ")
|
|
507
|
-
branch = ""
|
|
508
|
-
elif line.startswith("branch refs/heads/"):
|
|
509
|
-
branch = line.removeprefix("branch refs/heads/")
|
|
510
|
-
|
|
511
|
-
flush()
|
|
512
|
-
return mapping
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
def worktree_for_branch(branch: str, cwd: str | Path | None = None) -> str:
|
|
516
|
-
"""The worktree path holding ``branch``, or "" when no worktree does."""
|
|
517
|
-
return worktree_branch_map(cwd).get(branch, "")
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
def worktree_is_clean(path: str | Path) -> bool:
|
|
521
|
-
"""True when the worktree at ``path`` has no modified or untracked files.
|
|
522
|
-
|
|
523
|
-
Ignored build output (node_modules, .next) does not count as dirty, because
|
|
524
|
-
``git status --porcelain`` does not list ignored paths.
|
|
525
|
-
"""
|
|
526
|
-
completed = run(["git", "-C", str(path), "status", "--porcelain"], check=False, capture=True)
|
|
527
|
-
# A non-zero exit means the path is not a working tree at all. Treating
|
|
528
|
-
# that as "clean" would let a caller delete a directory it never inspected.
|
|
529
|
-
if completed.returncode != 0:
|
|
530
|
-
return False
|
|
531
|
-
return not (completed.stdout or "").strip()
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
def branch_is_pushed(branch: str, cwd: str | Path | None = None) -> bool:
|
|
535
|
-
"""True when every commit on ``branch`` is already on its remote.
|
|
536
|
-
|
|
537
|
-
Prefers the configured upstream and falls back to ``origin/<branch>``.
|
|
538
|
-
"""
|
|
539
|
-
upstream = run_output(
|
|
540
|
-
["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", f"{branch}@{{upstream}}"],
|
|
541
|
-
check=False,
|
|
542
|
-
cwd=cwd,
|
|
543
|
-
).strip()
|
|
544
|
-
if not upstream:
|
|
545
|
-
upstream = f"origin/{branch}"
|
|
546
|
-
|
|
547
|
-
exists = run(
|
|
548
|
-
["git", "rev-parse", "--verify", "--quiet", upstream], check=False, capture=True, cwd=cwd
|
|
549
|
-
)
|
|
550
|
-
if exists.returncode != 0:
|
|
551
|
-
return False
|
|
552
|
-
|
|
553
|
-
ancestor = run(
|
|
554
|
-
["git", "merge-base", "--is-ancestor", branch, upstream],
|
|
555
|
-
check=False,
|
|
556
|
-
capture=True,
|
|
557
|
-
cwd=cwd,
|
|
558
|
-
)
|
|
559
|
-
return ancestor.returncode == 0
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
def worktree_paths(cwd: str | Path | None = None) -> list[str]:
|
|
563
|
-
"""Every worktree path in ``git worktree list --porcelain`` order.
|
|
564
|
-
|
|
565
|
-
The first entry is always the main worktree; callers drop it themselves so
|
|
566
|
-
the distinction stays explicit at the call site.
|
|
567
|
-
|
|
568
|
-
The shell version parsed this with ``awk '{print $2}'``, which splits on
|
|
569
|
-
whitespace and so truncated any worktree path containing a space. Taking
|
|
570
|
-
the whole remainder of the line keeps such paths intact.
|
|
571
|
-
"""
|
|
572
|
-
return [
|
|
573
|
-
line.removeprefix("worktree ")
|
|
574
|
-
for line in run_output(["git", "worktree", "list", "--porcelain"], cwd=cwd).splitlines()
|
|
575
|
-
if line.startswith("worktree ")
|
|
576
|
-
]
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
def main_worktree(cwd: str | Path | None = None) -> str:
|
|
580
|
-
"""The main worktree's path - the first record git lists, always."""
|
|
581
|
-
paths = worktree_paths(cwd)
|
|
582
|
-
return paths[0] if paths else ""
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
def find_main_branch(cwd: str | Path | None = None) -> str:
|
|
586
|
-
"""The local main branch, preferring ``main`` over ``master``, or "" for neither.
|
|
587
|
-
|
|
588
|
-
Takes an explicit ``cwd`` because callers run it after moving to the main
|
|
589
|
-
worktree; relying on the process working directory would make the answer
|
|
590
|
-
depend on where the script happened to be invoked from.
|
|
591
|
-
"""
|
|
592
|
-
for candidate in ("main", "master"):
|
|
593
|
-
exists = run(
|
|
594
|
-
["git", "show-ref", "--verify", "--quiet", f"refs/heads/{candidate}"],
|
|
595
|
-
check=False,
|
|
596
|
-
capture=True,
|
|
597
|
-
cwd=cwd,
|
|
598
|
-
)
|
|
599
|
-
if exists.returncode == 0:
|
|
600
|
-
return candidate
|
|
601
|
-
return ""
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
# ---------------------------------------------------------------------------
|
|
605
|
-
# ffprobe
|
|
606
|
-
# ---------------------------------------------------------------------------
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
def ffprobe_entries(
|
|
610
|
-
path: Path,
|
|
611
|
-
entries: str,
|
|
612
|
-
*,
|
|
613
|
-
stream: str | None = None,
|
|
614
|
-
fmt: str = "csv=p=0",
|
|
615
|
-
on_error: str = "",
|
|
616
|
-
) -> str:
|
|
617
|
-
"""Read metadata out of a media file with ffprobe.
|
|
618
|
-
|
|
619
|
-
``entries`` is passed straight to ``-show_entries`` (e.g.
|
|
620
|
-
``stream=codec_name``). ``stream`` selects a stream when given (e.g.
|
|
621
|
-
``v:0``).
|
|
622
|
-
|
|
623
|
-
Returns ``on_error`` when ffprobe itself fails, and "" when it succeeds but
|
|
624
|
-
the field is absent. Those are different situations - a corrupt file versus
|
|
625
|
-
a file with no audio track - and callers need to tell them apart.
|
|
626
|
-
"""
|
|
627
|
-
argv = ["ffprobe", "-v", "error"]
|
|
628
|
-
if stream is not None:
|
|
629
|
-
argv += ["-select_streams", stream]
|
|
630
|
-
argv += ["-show_entries", entries, "-of", fmt, str(path)]
|
|
631
|
-
|
|
632
|
-
completed = run(argv, check=False, capture=True)
|
|
633
|
-
if completed.returncode != 0:
|
|
634
|
-
return on_error
|
|
635
|
-
return (completed.stdout or "").strip()
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
def ffprobe_json(path: Path, argv_extra: Sequence[str]) -> dict:
|
|
639
|
-
"""Run ffprobe with ``-of json`` and parse the result.
|
|
640
|
-
|
|
641
|
-
Returns an empty dict when ffprobe fails or emits unparseable output, so
|
|
642
|
-
callers can treat an unreadable file as "no streams" rather than crashing
|
|
643
|
-
partway through a large library scan.
|
|
644
|
-
"""
|
|
645
|
-
argv = ["ffprobe", "-v", "error", *argv_extra, "-of", "json", str(path)]
|
|
646
|
-
completed = run(argv, check=False, capture=True)
|
|
647
|
-
if completed.returncode != 0:
|
|
648
|
-
return {}
|
|
649
|
-
try:
|
|
650
|
-
parsed = json.loads(completed.stdout or "{}")
|
|
651
|
-
except json.JSONDecodeError:
|
|
652
|
-
return {}
|
|
653
|
-
return parsed if isinstance(parsed, dict) else {}
|
|
654
|
-
|
|
655
|
-
|
|
656
522
|
# ---------------------------------------------------------------------------
|
|
657
523
|
# Filesystem
|
|
658
524
|
# ---------------------------------------------------------------------------
|
|
659
525
|
|
|
660
526
|
|
|
661
|
-
def
|
|
662
|
-
"""
|
|
527
|
+
def report_walk_error(exc: OSError) -> None:
|
|
528
|
+
"""``os.walk``'s ``onerror``: report an unreadable directory and carry on.
|
|
663
529
|
|
|
664
|
-
|
|
665
|
-
|
|
530
|
+
find(1) printed "Permission denied" to stderr and carried on. Staying
|
|
531
|
+
silent would make a half-readable SMB share look fully scanned. stderr, not
|
|
532
|
+
the usual :func:`warning`, so these lines never interleave into output a
|
|
533
|
+
caller is piping.
|
|
666
534
|
"""
|
|
667
|
-
|
|
535
|
+
print(f"find: {exc.filename}: {exc.strerror}", file=sys.stderr, flush=True)
|
|
668
536
|
|
|
669
537
|
|
|
670
538
|
def iter_files(
|
|
@@ -708,14 +576,7 @@ def iter_files(
|
|
|
708
576
|
if extensions is not None:
|
|
709
577
|
patterns = tuple(f"*.{ext}" if case_sensitive else f"*.{ext.lower()}" for ext in extensions)
|
|
710
578
|
|
|
711
|
-
|
|
712
|
-
# find(1) printed "Permission denied" to stderr and carried on. Staying
|
|
713
|
-
# silent would make a half-readable SMB share look fully scanned.
|
|
714
|
-
# stderr, not the usual warning() helper, so these lines never
|
|
715
|
-
# interleave into output a caller is piping.
|
|
716
|
-
print(f"find: {exc.filename}: {exc.strerror}", file=sys.stderr, flush=True)
|
|
717
|
-
|
|
718
|
-
for dirpath, dirnames, filenames in os.walk(root, followlinks=False, onerror=_on_error):
|
|
579
|
+
for dirpath, dirnames, filenames in os.walk(root, followlinks=False, onerror=report_walk_error):
|
|
719
580
|
dirnames.sort()
|
|
720
581
|
if skip_hidden:
|
|
721
582
|
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
|
|
@@ -770,6 +631,73 @@ def scan_root(raw: str, *, flag: str = "--path") -> Path:
|
|
|
770
631
|
return path
|
|
771
632
|
|
|
772
633
|
|
|
634
|
+
def _umask() -> int:
|
|
635
|
+
current = os.umask(0)
|
|
636
|
+
os.umask(current)
|
|
637
|
+
return current
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def atomic_write_text(path: Path, text: str, *, mode: int | None = None) -> None:
|
|
641
|
+
"""Replace ``path``'s contents so a crash leaves the old file or the new one.
|
|
642
|
+
|
|
643
|
+
The text goes to a temporary file beside the target, which is then renamed
|
|
644
|
+
over it. The target is ``path`` resolved, so a symlinked dotfile (a
|
|
645
|
+
``~/.zshrc`` that points into a dotfiles repo) has its target rewritten and
|
|
646
|
+
the link itself survives. An existing file keeps its permission bits; a new
|
|
647
|
+
one gets ``mode``, or the usual ``0o666`` less the umask where ``mkstemp``
|
|
648
|
+
alone would leave it ``0o600``.
|
|
649
|
+
|
|
650
|
+
UTF-8 with ``surrogateescape``: bytes that are not UTF-8, read the same way,
|
|
651
|
+
are written back exactly as they came in.
|
|
652
|
+
|
|
653
|
+
A hard-linked file is written in place instead. Renaming over one of its
|
|
654
|
+
names would split it from the others, which would keep the old contents.
|
|
655
|
+
"""
|
|
656
|
+
target = path.resolve()
|
|
657
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
658
|
+
|
|
659
|
+
if target.exists() and target.stat().st_nlink > 1:
|
|
660
|
+
with target.open("w", encoding="utf-8", errors="surrogateescape") as handle:
|
|
661
|
+
handle.write(text)
|
|
662
|
+
if mode is not None:
|
|
663
|
+
target.chmod(mode)
|
|
664
|
+
return
|
|
665
|
+
|
|
666
|
+
descriptor, temp_name = tempfile.mkstemp(dir=target.parent, prefix=f".{target.name}.")
|
|
667
|
+
temp = Path(temp_name)
|
|
668
|
+
try:
|
|
669
|
+
with os.fdopen(descriptor, "w", encoding="utf-8", errors="surrogateescape") as handle:
|
|
670
|
+
handle.write(text)
|
|
671
|
+
handle.flush()
|
|
672
|
+
os.fsync(handle.fileno())
|
|
673
|
+
if mode is not None:
|
|
674
|
+
temp.chmod(mode)
|
|
675
|
+
elif target.exists():
|
|
676
|
+
shutil.copymode(target, temp)
|
|
677
|
+
else:
|
|
678
|
+
temp.chmod(0o666 & ~_umask())
|
|
679
|
+
temp.replace(target)
|
|
680
|
+
except BaseException:
|
|
681
|
+
temp.unlink(missing_ok=True)
|
|
682
|
+
raise
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def remove_or_warn(path: Path, remove: Callable[[Path], None] = Path.unlink) -> bool:
|
|
686
|
+
"""Delete ``path`` (``remove=Path.rmdir`` for a directory), or say why not.
|
|
687
|
+
|
|
688
|
+
Returns whether it went. A failure is reported and left to the caller to
|
|
689
|
+
count: the shell versions aborted the whole run here under ``set -e`` (or,
|
|
690
|
+
in ``delete-smb-files``, hid it with ``2>/dev/null``), and on a NAS one busy
|
|
691
|
+
file should not strand the rest of a sweep.
|
|
692
|
+
"""
|
|
693
|
+
try:
|
|
694
|
+
remove(path)
|
|
695
|
+
except OSError as exc:
|
|
696
|
+
warning(f" ✗ Failed to delete {path}: {exc}")
|
|
697
|
+
return False
|
|
698
|
+
return True
|
|
699
|
+
|
|
700
|
+
|
|
773
701
|
def normalize_extensions(raw: str, *, flag: str = "--ext") -> list[str]:
|
|
774
702
|
"""Split a comma-separated extension list, preserving glob metacharacters.
|
|
775
703
|
|