@codebend3r/che 1.0.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/LICENSE +21 -0
- package/README.md +424 -0
- package/bin/che.py +869 -0
- package/bin/commands.py +872 -0
- package/bin/drives/eject-all-drives.py +203 -0
- package/bin/drives/mount-all-drives.py +140 -0
- package/bin/drives/ping-nas.py +242 -0
- package/bin/files/compress-folders.py +188 -0
- package/bin/files/delete-by-ext.py +108 -0
- package/bin/files/delete-empty-folders.py +136 -0
- package/bin/files/delete-smb-files.py +105 -0
- package/bin/files/files-under-size.py +132 -0
- package/bin/files/find-largest-files.py +102 -0
- package/bin/files/make-alpha-dir.py +63 -0
- package/bin/git/all-actions.py +545 -0
- package/bin/git/checkout-my-branches.py +147 -0
- package/bin/git/clean-stale-branches.py +161 -0
- package/bin/git/prune-worktrees.py +143 -0
- package/bin/git/sync-all-branches.py +368 -0
- package/bin/git/update-local-branches.py +195 -0
- package/bin/install.py +1515 -0
- package/bin/shellgen.py +782 -0
- package/bin/system/btop-launch.py +114 -0
- package/bin/system/update-brew.py +146 -0
- package/bin/tui.py +648 -0
- package/bin/utils.py +836 -0
- package/bin/video/delete-duplicate-videos.py +313 -0
- package/bin/video/detect-green-magenta-videos.py +268 -0
- package/bin/video/find-movie-by-year.py +84 -0
- package/bin/video/find-video-mkv-issues.py +242 -0
- package/bin/video/fix-codecs.py +209 -0
- package/bin/video/largest-tv-shows.py +175 -0
- package/bin/video/remove-metadata.py +126 -0
- package/bin/video/rename-video-file.py +329 -0
- package/bin/video/scan-videos-audio-language.py +110 -0
- package/bin/video/show-codecs.py +111 -0
- package/bin/video/validate-video-files.py +114 -0
- package/bin/video/video-list.py +120 -0
- package/package.json +54 -0
- package/shell/che.bash +382 -0
- package/shell/che.fish +282 -0
- package/shell/che.zsh +389 -0
- package/shell/completions/_che +146 -0
- package/shell/completions/che.bash +107 -0
- package/shell/completions/che.fish +371 -0
package/bin/utils.py
ADDED
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
"""Shared helpers for the scripts under ``bin/<category>/``.
|
|
2
|
+
|
|
3
|
+
This is the Python counterpart of the older ``bin/utils.sh``. Scripts pick it
|
|
4
|
+
up with the same shape the shell scripts used for sourcing::
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
10
|
+
from utils import info, warning, run
|
|
11
|
+
|
|
12
|
+
Everything here is standard library only, on purpose: the scripts have to run
|
|
13
|
+
from a bare ``python3`` on any machine this repo is cloned to, with no venv and
|
|
14
|
+
no install step.
|
|
15
|
+
|
|
16
|
+
=============================================================================
|
|
17
|
+
CLI conventions - shared patterns for bin/<category>/*.py
|
|
18
|
+
|
|
19
|
+
* Long flags only: ``--name=value`` for values (no space-separated ``-o val``
|
|
20
|
+
style in these scripts unless a specific tool documents otherwise).
|
|
21
|
+
* Booleans: a bare ``--flag`` enables; the same flag also honors
|
|
22
|
+
``--flag=true|false`` (and yes|no, 1|0). ``add_bool_flag`` builds both forms.
|
|
23
|
+
A bare ``--flag`` never consumes the following argument, matching the shell
|
|
24
|
+
scripts' ``while [[ $# -gt 0 ]]`` loop.
|
|
25
|
+
* Help: ``-h`` / ``--help`` prints the script's usage.
|
|
26
|
+
* Parsing style: ``build_parser`` so every script handles argv the same way.
|
|
27
|
+
=============================================================================
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
import shutil
|
|
37
|
+
import subprocess
|
|
38
|
+
import sys
|
|
39
|
+
from collections.abc import Callable, Iterable, Iterator, Sequence
|
|
40
|
+
from fnmatch import fnmatchcase
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from typing import NoReturn
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Colors
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
NC = "\033[0m" # No Color
|
|
49
|
+
RED = "\033[1;31m"
|
|
50
|
+
GREEN = "\033[0;32m"
|
|
51
|
+
YELLOW = "\033[1;33m"
|
|
52
|
+
CYAN = "\033[1;36m"
|
|
53
|
+
MAGENTA = "\033[1;35m"
|
|
54
|
+
|
|
55
|
+
_FORCE_COLOR_OFF = {"", "0", "false", "no", "off"}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _color_enabled() -> bool:
|
|
59
|
+
"""Honor NO_COLOR and non-tty output the way a well-behaved CLI should.
|
|
60
|
+
|
|
61
|
+
DELIBERATE DIVERGENCE from ``utils.sh``, which always emitted escapes.
|
|
62
|
+
Suppressing them when stdout is not a terminal keeps captured output clean
|
|
63
|
+
for the tests and for shell pipelines. ``FORCE_COLOR`` overrides, following
|
|
64
|
+
the usual convention that ``FORCE_COLOR=0`` means off, not on.
|
|
65
|
+
|
|
66
|
+
The tty check happens at call time, not import time, so
|
|
67
|
+
``contextlib.redirect_stdout`` and pytest's capture are both respected.
|
|
68
|
+
"""
|
|
69
|
+
force = os.environ.get("FORCE_COLOR")
|
|
70
|
+
if force is not None and force.lower() not in _FORCE_COLOR_OFF:
|
|
71
|
+
return True
|
|
72
|
+
if os.environ.get("NO_COLOR"):
|
|
73
|
+
return False
|
|
74
|
+
try:
|
|
75
|
+
return sys.stdout.isatty()
|
|
76
|
+
except (ValueError, AttributeError):
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def color_enabled() -> bool:
|
|
81
|
+
"""Public form of :func:`_color_enabled`, for callers that build their own
|
|
82
|
+
escape sequences (per-language colouring, table headers) and must honour
|
|
83
|
+
``NO_COLOR`` the same way the log helpers do."""
|
|
84
|
+
return _color_enabled()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def log_color(color: str, message: str) -> None:
|
|
88
|
+
"""Print ``message`` wrapped in ``color``.
|
|
89
|
+
|
|
90
|
+
The shell original used ``printf '%b%b%b\\n'``, whose ``%b`` also expanded
|
|
91
|
+
backslash escapes *inside the message* - so a Windows path or a regex in a
|
|
92
|
+
log line came out mangled, and a stray ``\\c`` truncated the output. That
|
|
93
|
+
escape expansion is deliberately not reproduced; messages print literally.
|
|
94
|
+
|
|
95
|
+
Flushed on every line. Python block-buffers stdout when it is a pipe, but
|
|
96
|
+
a subprocess writes its stderr straight to fd 2 - without the flush, git's
|
|
97
|
+
error messages appear *before* the banner that introduced them whenever the
|
|
98
|
+
output is piped to a file or a CI log.
|
|
99
|
+
"""
|
|
100
|
+
if _color_enabled():
|
|
101
|
+
print(f"{color}{message}{NC}", flush=True)
|
|
102
|
+
else:
|
|
103
|
+
print(message, flush=True)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def log(message: str) -> None:
|
|
107
|
+
"""Green. General progress."""
|
|
108
|
+
log_color(GREEN, message)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def warning(message: str) -> None:
|
|
112
|
+
"""Red. Errors and destructive-action notices."""
|
|
113
|
+
log_color(RED, message)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def info(message: str) -> None:
|
|
117
|
+
"""Cyan. Informational detail."""
|
|
118
|
+
log_color(CYAN, message)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def note(message: str) -> None:
|
|
122
|
+
"""Yellow. Headers and secondary detail."""
|
|
123
|
+
log_color(YELLOW, message)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def success(message: str) -> None:
|
|
127
|
+
"""Magenta. Summaries and completion."""
|
|
128
|
+
log_color(MAGENTA, message)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
# Formatting
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
_KB = 1024
|
|
136
|
+
_MB = _KB * 1024
|
|
137
|
+
_GB = _MB * 1024
|
|
138
|
+
_TB = _GB * 1024
|
|
139
|
+
_PB = _TB * 1024
|
|
140
|
+
|
|
141
|
+
_UNITS: tuple[tuple[int, str], ...] = (
|
|
142
|
+
(_PB, "PB"),
|
|
143
|
+
(_TB, "TB"),
|
|
144
|
+
(_GB, "GB"),
|
|
145
|
+
(_MB, "MB"),
|
|
146
|
+
(_KB, "KB"),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def format_bytes(num_bytes: int) -> str:
|
|
151
|
+
"""Format a byte count as PB/TB/GB/MB/KB, or plain bytes below 1 KB.
|
|
152
|
+
|
|
153
|
+
Truncates rather than rounds, matching ``utils.sh``, which piped through
|
|
154
|
+
``bc`` with ``scale=2``. ``2043`` bytes is ``1.99512...`` KB and renders as
|
|
155
|
+
``1.99 KB``, not ``2.00 KB``. The arithmetic is exact integer maths, so the
|
|
156
|
+
truncation holds at every magnitude.
|
|
157
|
+
|
|
158
|
+
Output is identical to the shell version for every value representable in
|
|
159
|
+
bash's 64-bit signed arithmetic. Above 2^63 bash wrapped and fell through
|
|
160
|
+
to a raw byte count; this version keeps formatting correctly.
|
|
161
|
+
"""
|
|
162
|
+
num_bytes = int(num_bytes)
|
|
163
|
+
|
|
164
|
+
for size, unit in _UNITS:
|
|
165
|
+
if num_bytes >= size:
|
|
166
|
+
# Integer maths, so the truncation is exact - Decimal's default
|
|
167
|
+
# 28-digit context would round before the truncation could apply.
|
|
168
|
+
hundredths = (num_bytes * 100) // size
|
|
169
|
+
return f"{hundredths // 100}.{hundredths % 100:02d} {unit}"
|
|
170
|
+
|
|
171
|
+
return f"{num_bytes} B"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
# Argument parsing
|
|
176
|
+
# ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
_TRUE_VALUES = {"true", "yes", "1", "on"}
|
|
179
|
+
_FALSE_VALUES = {"false", "no", "0", "off"}
|
|
180
|
+
|
|
181
|
+
# Suffix for the hidden companion option that sets a bool flag to False.
|
|
182
|
+
# Deliberately ugly so it cannot collide with a real flag name.
|
|
183
|
+
_FALSE_SUFFIX = "-set-false-internal"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def parse_bool(value: str | bool) -> bool:
|
|
187
|
+
"""Parse a boolean spelling.
|
|
188
|
+
|
|
189
|
+
Accepts ``true|yes|1|on`` and ``false|no|0|off``, case-insensitive, and
|
|
190
|
+
raises ``ValueError`` on anything else - so a typo like ``--dry-run=ture``
|
|
191
|
+
is a hard error rather than a silent falsy that deletes the user's files.
|
|
192
|
+
|
|
193
|
+
This is intentionally WIDER than the shell scripts, which tested dry-run
|
|
194
|
+
with ``if $DRY_RUN; then`` - literally executing the value as a command.
|
|
195
|
+
There ``true``/``false`` worked, ``1``/``0``/``on``/``off``/``no`` were
|
|
196
|
+
"command not found", and ``yes`` invoked ``/usr/bin/yes`` and hung forever.
|
|
197
|
+
"""
|
|
198
|
+
if isinstance(value, bool):
|
|
199
|
+
return value
|
|
200
|
+
|
|
201
|
+
lowered = value.strip().lower()
|
|
202
|
+
if lowered in _TRUE_VALUES:
|
|
203
|
+
return True
|
|
204
|
+
if lowered in _FALSE_VALUES:
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
raise ValueError(f"expected true/false (got: {value!r})")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class BoolFlagParser(argparse.ArgumentParser):
|
|
211
|
+
"""``ArgumentParser`` that handles ``--flag`` and ``--flag=value`` safely.
|
|
212
|
+
|
|
213
|
+
argparse has no built-in for that pair. The obvious spelling,
|
|
214
|
+
``nargs="?"``, is a trap: it makes a bare ``--flag`` swallow the *next*
|
|
215
|
+
argument whenever that argument parses as a boolean, so ``--dry-run 0``
|
|
216
|
+
silently becomes ``dry_run=False`` and deletes for real. The shell scripts
|
|
217
|
+
could never do that.
|
|
218
|
+
|
|
219
|
+
Instead, ``--flag=value`` tokens are rewritten before argparse sees them:
|
|
220
|
+
to ``--flag`` (a plain ``store_true``) for a true value, and to a hidden
|
|
221
|
+
``store_false`` companion for a false one. Rewriting in place keeps
|
|
222
|
+
left-to-right precedence, so a later flag still wins over an earlier one.
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
226
|
+
super().__init__(*args, **kwargs)
|
|
227
|
+
self._bool_flags: dict[str, str] = {} # "--dry-run" -> "--dry-run-set-false-internal"
|
|
228
|
+
|
|
229
|
+
def register_bool_flag(self, name: str, false_name: str) -> None:
|
|
230
|
+
self._bool_flags[name] = false_name
|
|
231
|
+
|
|
232
|
+
def _rewrite(self, argv: Sequence[str]) -> list[str]:
|
|
233
|
+
rewritten: list[str] = []
|
|
234
|
+
for index, arg in enumerate(argv):
|
|
235
|
+
# Everything after a bare `--` is a literal positional, so it must
|
|
236
|
+
# pass through untouched - rewriting it would report an error
|
|
237
|
+
# naming an internal flag the user never typed.
|
|
238
|
+
if arg == "--":
|
|
239
|
+
rewritten.extend(argv[index:])
|
|
240
|
+
break
|
|
241
|
+
|
|
242
|
+
flag, sep, value = arg.partition("=")
|
|
243
|
+
if not sep or flag not in self._bool_flags:
|
|
244
|
+
rewritten.append(arg)
|
|
245
|
+
continue
|
|
246
|
+
try:
|
|
247
|
+
enabled = parse_bool(value)
|
|
248
|
+
except ValueError as exc:
|
|
249
|
+
self.error(f"argument {flag}: {exc}")
|
|
250
|
+
rewritten.append(flag if enabled else self._bool_flags[flag])
|
|
251
|
+
return rewritten
|
|
252
|
+
|
|
253
|
+
def parse_args(self, args=None, namespace=None): # type: ignore[override]
|
|
254
|
+
argv = list(sys.argv[1:] if args is None else args)
|
|
255
|
+
return super().parse_args(self._rewrite(argv), namespace)
|
|
256
|
+
|
|
257
|
+
def parse_known_args(self, args=None, namespace=None): # type: ignore[override]
|
|
258
|
+
argv = list(sys.argv[1:] if args is None else args)
|
|
259
|
+
return super().parse_known_args(self._rewrite(argv), namespace)
|
|
260
|
+
|
|
261
|
+
def error(self, message: str) -> NoReturn:
|
|
262
|
+
"""Print the message plus full usage and exit 1, as the shell did.
|
|
263
|
+
|
|
264
|
+
argparse's default is a one-line usage and exit code 2. Every shell
|
|
265
|
+
script here printed ``❌ Unknown argument: X`` followed by the whole
|
|
266
|
+
usage block and exited 1, and the ``.zshrc`` chains distinguish 1 from
|
|
267
|
+
other codes.
|
|
268
|
+
|
|
269
|
+
Annotated ``NoReturn`` so callers - notably ``_rewrite``, which falls
|
|
270
|
+
through to use a variable this branch never assigns - are provably safe.
|
|
271
|
+
"""
|
|
272
|
+
# The hidden companion option is an implementation detail; a user who
|
|
273
|
+
# typed `--dry-run=false` should not be told about it by name.
|
|
274
|
+
warning(f"❌ {message.replace(_FALSE_SUFFIX, '')}")
|
|
275
|
+
self.print_help()
|
|
276
|
+
sys.exit(1)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def build_parser(prog: str, description: str, epilog: str = "") -> BoolFlagParser:
|
|
280
|
+
"""A parser pre-set to this repo's conventions.
|
|
281
|
+
|
|
282
|
+
``allow_abbrev=False`` matters: without it argparse silently accepts
|
|
283
|
+
``--dry`` for ``--dry-run``, which the shell scripts never did.
|
|
284
|
+
"""
|
|
285
|
+
return BoolFlagParser(
|
|
286
|
+
prog=prog,
|
|
287
|
+
description=description,
|
|
288
|
+
epilog=epilog,
|
|
289
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
290
|
+
allow_abbrev=False,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def add_bool_flag(
|
|
295
|
+
parser: BoolFlagParser,
|
|
296
|
+
name: str,
|
|
297
|
+
*,
|
|
298
|
+
default: bool = False,
|
|
299
|
+
help: str = "",
|
|
300
|
+
dest: str | None = None,
|
|
301
|
+
allow_value: bool = True,
|
|
302
|
+
) -> None:
|
|
303
|
+
"""Add a flag accepting both ``--flag`` and ``--flag=true|false``.
|
|
304
|
+
|
|
305
|
+
Both spellings are needed in most places: the shell scripts supported them,
|
|
306
|
+
and the ``.zshrc`` wrappers rely on the ``=value`` form to flip dry-run
|
|
307
|
+
defaults. See :class:`BoolFlagParser` for why this is not ``nargs="?"``.
|
|
308
|
+
|
|
309
|
+
Pass ``allow_value=False`` for an inverted flag such as ``--no-force``.
|
|
310
|
+
The shell scripts only ever accepted the bare spelling for those, and
|
|
311
|
+
``--no-force=false`` is a double negative nobody should have to decode: it
|
|
312
|
+
would silently mean "do force", which is the opposite of how it reads on a
|
|
313
|
+
script that unmounts volumes.
|
|
314
|
+
"""
|
|
315
|
+
dest = dest or name.removeprefix("--").replace("-", "_")
|
|
316
|
+
|
|
317
|
+
parser.add_argument(name, action="store_true", dest=dest, default=default, help=help)
|
|
318
|
+
|
|
319
|
+
if not allow_value:
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
false_name = f"{name}{_FALSE_SUFFIX}"
|
|
323
|
+
parser.add_argument(
|
|
324
|
+
false_name, action="store_false", dest=dest, default=default, help=argparse.SUPPRESS
|
|
325
|
+
)
|
|
326
|
+
parser.register_bool_flag(name, false_name)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def env_bool(name: str, default: bool) -> bool:
|
|
330
|
+
"""Read a boolean from the environment, for the ``DRY_RUN=true`` wrappers.
|
|
331
|
+
|
|
332
|
+
An unset or empty variable falls back to ``default``. An unparseable value
|
|
333
|
+
exits non-zero rather than guessing, because guessing wrong on ``DRY_RUN``
|
|
334
|
+
means deleting files the user expected only to preview.
|
|
335
|
+
"""
|
|
336
|
+
raw = os.environ.get(name)
|
|
337
|
+
if raw is None or raw == "":
|
|
338
|
+
return default
|
|
339
|
+
try:
|
|
340
|
+
return parse_bool(raw)
|
|
341
|
+
except ValueError as exc:
|
|
342
|
+
warning(f"❌ Environment variable {name}: {exc}")
|
|
343
|
+
sys.exit(1)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# ---------------------------------------------------------------------------
|
|
347
|
+
# Subprocess
|
|
348
|
+
# ---------------------------------------------------------------------------
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def require_binary(name: str, *, hint: str = "") -> str:
|
|
352
|
+
"""Exit with a clear message when an external tool is missing.
|
|
353
|
+
|
|
354
|
+
Serves the same role as the ``require_binary`` helper in
|
|
355
|
+
``bin/video/find-video-mkv-issues.sh``, with a friendlier message and an
|
|
356
|
+
optional install hint. Returns the resolved path so the caller can pass an
|
|
357
|
+
absolute program name to :func:`run`.
|
|
358
|
+
|
|
359
|
+
Note this resolves PATH executables only, where bash's ``command -v`` also
|
|
360
|
+
found builtins, functions and aliases. Every binary this repo requires is a
|
|
361
|
+
real executable, so the distinction does not bite in practice.
|
|
362
|
+
"""
|
|
363
|
+
resolved = shutil.which(name)
|
|
364
|
+
if resolved is None:
|
|
365
|
+
warning(f"❌ '{name}' is not installed or not in PATH.")
|
|
366
|
+
if hint:
|
|
367
|
+
info(f"💡 {hint}")
|
|
368
|
+
sys.exit(1)
|
|
369
|
+
return resolved
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def run(
|
|
373
|
+
argv: Sequence[str],
|
|
374
|
+
*,
|
|
375
|
+
check: bool = True,
|
|
376
|
+
capture: bool = False,
|
|
377
|
+
cwd: str | Path | None = None,
|
|
378
|
+
text: bool = True,
|
|
379
|
+
env: dict[str, str] | None = None,
|
|
380
|
+
stdin: int | None = None,
|
|
381
|
+
timeout: float | None = None,
|
|
382
|
+
) -> subprocess.CompletedProcess[str]:
|
|
383
|
+
"""Run an external command.
|
|
384
|
+
|
|
385
|
+
Always an argv list, never a string, and never ``shell=True`` - the two
|
|
386
|
+
rules that keep quoting bugs and injection out of a script that handles
|
|
387
|
+
arbitrary filenames. ``check=True`` by default so a failure raises instead
|
|
388
|
+
of being sailed past, which is what ``set -e`` bought the shell versions.
|
|
389
|
+
|
|
390
|
+
``env`` ADDS to the current environment rather than replacing it, matching
|
|
391
|
+
the shell idiom ``VAR=x cmd``. Replacing it outright would drop
|
|
392
|
+
``/opt/homebrew/bin`` from PATH and make ffmpeg and friends vanish.
|
|
393
|
+
"""
|
|
394
|
+
merged_env = {**os.environ, **env} if env else None
|
|
395
|
+
|
|
396
|
+
return subprocess.run( # noqa: S603 - argv list, shell=False, by construction
|
|
397
|
+
list(argv),
|
|
398
|
+
check=check,
|
|
399
|
+
capture_output=capture,
|
|
400
|
+
cwd=cwd,
|
|
401
|
+
text=text,
|
|
402
|
+
# errors="replace" rather than the default "strict": a single
|
|
403
|
+
# non-UTF-8 byte in a child's diagnostic (a legacy-encoded title tag
|
|
404
|
+
# echoed back by ffmpeg, say) would otherwise raise UnicodeDecodeError
|
|
405
|
+
# and abort a whole library scan partway through.
|
|
406
|
+
errors="replace" if text else None,
|
|
407
|
+
env=merged_env,
|
|
408
|
+
stdin=stdin,
|
|
409
|
+
timeout=timeout,
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def run_output(argv: Sequence[str], *, check: bool = True, cwd: str | Path | None = None) -> str:
|
|
414
|
+
"""Run a command and return its stdout with the trailing newline stripped.
|
|
415
|
+
|
|
416
|
+
The equivalent of ``$(cmd)``. With ``check=False`` this is the equivalent of
|
|
417
|
+
``$(cmd) || true``: a non-zero exit still returns whatever stdout was
|
|
418
|
+
produced, and a missing binary returns "" instead of raising.
|
|
419
|
+
"""
|
|
420
|
+
try:
|
|
421
|
+
completed = run(argv, check=check, capture=True, cwd=cwd)
|
|
422
|
+
except FileNotFoundError:
|
|
423
|
+
if check:
|
|
424
|
+
raise
|
|
425
|
+
return ""
|
|
426
|
+
return (completed.stdout or "").rstrip("\n")
|
|
427
|
+
|
|
428
|
+
|
|
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
|
+
def run_cli(entry: Callable[[], int]) -> None:
|
|
444
|
+
"""Run a script's ``main`` and translate exceptions into shell exit codes.
|
|
445
|
+
|
|
446
|
+
Every script ends with ``run_cli(main)`` so error handling is identical
|
|
447
|
+
across the repo, rather than each file growing its own ``try/except``.
|
|
448
|
+
"""
|
|
449
|
+
try:
|
|
450
|
+
sys.exit(entry())
|
|
451
|
+
except subprocess.CalledProcessError as exc:
|
|
452
|
+
# Surface the child's own diagnostic. The shell versions let stderr
|
|
453
|
+
# through to the terminal; capturing it and printing only "command
|
|
454
|
+
# failed" would hide git's actual explanation.
|
|
455
|
+
if getattr(exc, "stderr", None):
|
|
456
|
+
sys.stderr.write(exc.stderr)
|
|
457
|
+
warning(f"❌ Command failed: {' '.join(str(part) for part in exc.cmd)}")
|
|
458
|
+
code = exc.returncode
|
|
459
|
+
# A negative returncode means the child died on a signal; shells report
|
|
460
|
+
# that as 128+N, and sys.exit(-9) would otherwise become 247.
|
|
461
|
+
sys.exit(128 - code if code and code < 0 else (code or 1))
|
|
462
|
+
except FileNotFoundError as exc:
|
|
463
|
+
warning(f"❌ {exc}")
|
|
464
|
+
sys.exit(127)
|
|
465
|
+
except OSError as exc:
|
|
466
|
+
warning(f"❌ {exc}")
|
|
467
|
+
sys.exit(1)
|
|
468
|
+
except KeyboardInterrupt:
|
|
469
|
+
sys.exit(130)
|
|
470
|
+
|
|
471
|
+
|
|
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
|
+
# ---------------------------------------------------------------------------
|
|
657
|
+
# Filesystem
|
|
658
|
+
# ---------------------------------------------------------------------------
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def is_macos_metadata(path: Path) -> bool:
|
|
662
|
+
"""True for the AppleDouble sidecars (``._foo``) the shell scripts skipped.
|
|
663
|
+
|
|
664
|
+
``.DS_Store`` is deliberately not included: the scripts that skip metadata
|
|
665
|
+
are matching the ``._*`` pattern specifically.
|
|
666
|
+
"""
|
|
667
|
+
return path.name.startswith("._")
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def iter_files(
|
|
671
|
+
root: Path,
|
|
672
|
+
*,
|
|
673
|
+
extensions: Iterable[str] | None = None,
|
|
674
|
+
recursive: bool = True,
|
|
675
|
+
skip_macos_metadata: bool = True,
|
|
676
|
+
skip_hidden: bool = False,
|
|
677
|
+
case_sensitive: bool = False,
|
|
678
|
+
) -> Iterator[Path]:
|
|
679
|
+
"""Walk ``root`` yielding files, the replacement for ``find ... -print0``.
|
|
680
|
+
|
|
681
|
+
Streams like ``find`` did: constant memory, first result immediately, and
|
|
682
|
+
entries are printed as they are found rather than after a full scan. On a
|
|
683
|
+
multi-TB media library that is the difference between instant output and a
|
|
684
|
+
minutes-long silent stall. Order within each directory is sorted, so runs
|
|
685
|
+
are reproducible without buffering the whole tree.
|
|
686
|
+
|
|
687
|
+
``extensions`` is matched against the end of the FILENAME, reproducing
|
|
688
|
+
``find -iname '*.ext'`` exactly - including for a file literally named
|
|
689
|
+
``.mkv``, which a ``Path.suffix`` check would miss because pathlib treats a
|
|
690
|
+
leading dot as the stem. Pass ``case_sensitive=True`` for the handful of
|
|
691
|
+
callers whose shell original used ``-name`` rather than ``-iname``.
|
|
692
|
+
|
|
693
|
+
Only regular files are yielded. ``find -type f`` excluded FIFOs, sockets
|
|
694
|
+
and devices, and a FIFO reaching an ``open()`` blocks forever - which on a
|
|
695
|
+
hashing pass would hang the whole scan.
|
|
696
|
+
|
|
697
|
+
Symlinked files are skipped and symlinked directories are not followed,
|
|
698
|
+
matching ``find``'s default (no ``-L``).
|
|
699
|
+
|
|
700
|
+
Raises ``FileNotFoundError`` when ``root`` is not a directory. ``find``
|
|
701
|
+
errored there too, and under ``set -e`` that aborted the run; silently
|
|
702
|
+
yielding nothing would make a typo'd ``--path`` look like a clean library.
|
|
703
|
+
"""
|
|
704
|
+
if not root.is_dir():
|
|
705
|
+
raise FileNotFoundError(f"Not a directory: {root}")
|
|
706
|
+
|
|
707
|
+
patterns: tuple[str, ...] | None = None
|
|
708
|
+
if extensions is not None:
|
|
709
|
+
patterns = tuple(f"*.{ext}" if case_sensitive else f"*.{ext.lower()}" for ext in extensions)
|
|
710
|
+
|
|
711
|
+
def _on_error(exc: OSError) -> None:
|
|
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):
|
|
719
|
+
dirnames.sort()
|
|
720
|
+
if skip_hidden:
|
|
721
|
+
dirnames[:] = [d for d in dirnames if not d.startswith(".")]
|
|
722
|
+
filenames.sort()
|
|
723
|
+
|
|
724
|
+
directory = Path(dirpath)
|
|
725
|
+
for name in filenames:
|
|
726
|
+
if skip_macos_metadata and name.startswith("._"):
|
|
727
|
+
continue
|
|
728
|
+
if skip_hidden and name.startswith("."):
|
|
729
|
+
continue
|
|
730
|
+
candidate = name if case_sensitive else name.lower()
|
|
731
|
+
if patterns is not None and not any(
|
|
732
|
+
fnmatchcase(candidate, pattern) for pattern in patterns
|
|
733
|
+
):
|
|
734
|
+
continue
|
|
735
|
+
path = directory / name
|
|
736
|
+
if path.is_symlink() or not path.is_file():
|
|
737
|
+
continue
|
|
738
|
+
yield path
|
|
739
|
+
|
|
740
|
+
if not recursive:
|
|
741
|
+
dirnames[:] = []
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def scan_root(raw: str, *, flag: str = "--path") -> Path:
|
|
745
|
+
"""Validate a user-supplied scan root, or exit with a clear message.
|
|
746
|
+
|
|
747
|
+
Refuses a symlinked root. That is not fussiness: ``find`` does not follow a
|
|
748
|
+
symlinked START POINT without ``-L``, so every shell script here was a
|
|
749
|
+
silent no-op when pointed at one. Quietly descending instead would turn
|
|
750
|
+
``delete-by-ext --path=~/media`` (a symlink to the NAS) from "does nothing"
|
|
751
|
+
into "deletes the library". Refusing is the only safe reading of a case the
|
|
752
|
+
shell version never actually handled.
|
|
753
|
+
"""
|
|
754
|
+
if not raw:
|
|
755
|
+
warning(f"❌ {flag} is required")
|
|
756
|
+
sys.exit(1)
|
|
757
|
+
|
|
758
|
+
path = Path(raw)
|
|
759
|
+
|
|
760
|
+
if path.is_symlink():
|
|
761
|
+
warning(f"❌ {flag} is a symlink: {raw}")
|
|
762
|
+
info("💡 find(1) does not follow a symlinked start point, so this was")
|
|
763
|
+
info(" previously a no-op. Pass the real path to act on it.")
|
|
764
|
+
sys.exit(1)
|
|
765
|
+
|
|
766
|
+
if not path.is_dir():
|
|
767
|
+
warning(f"❌ Directory not found: {raw}")
|
|
768
|
+
sys.exit(1)
|
|
769
|
+
|
|
770
|
+
return path
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def normalize_extensions(raw: str, *, flag: str = "--ext") -> list[str]:
|
|
774
|
+
"""Split a comma-separated extension list, preserving glob metacharacters.
|
|
775
|
+
|
|
776
|
+
Values are passed through verbatim (minus surrounding whitespace) so
|
|
777
|
+
``--ext=mp*`` still matches mp4/mp3/mpg, exactly as the shell version's
|
|
778
|
+
``-iname "*.$e"`` interpolation did.
|
|
779
|
+
|
|
780
|
+
A leading dot is REJECTED rather than stripped. ``--ext=.jpg`` built
|
|
781
|
+
``-iname "*..jpg"`` in the shell version, which matched nothing - so
|
|
782
|
+
stripping the dot would silently turn a harmless typo into a mass delete,
|
|
783
|
+
and keeping it would silently match nothing. An error is the honest answer.
|
|
784
|
+
"""
|
|
785
|
+
extensions = [part.strip() for part in raw.split(",")]
|
|
786
|
+
extensions = [ext for ext in extensions if ext]
|
|
787
|
+
|
|
788
|
+
for ext in extensions:
|
|
789
|
+
if ext.startswith("."):
|
|
790
|
+
warning(f"❌ {flag} entries must not start with a dot (got: {ext!r})")
|
|
791
|
+
info(f"💡 Use {flag}={ext.lstrip('.')} instead.")
|
|
792
|
+
sys.exit(1)
|
|
793
|
+
|
|
794
|
+
return extensions
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
_SIZE_RE = re.compile(r"([0-9]+)([a-z]*)")
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def parse_size(text: str) -> int:
|
|
801
|
+
"""Parse a human size like ``1MB``/``500k``/``2 GiB`` into bytes.
|
|
802
|
+
|
|
803
|
+
Ports ``parse_bytes`` from ``files-under-size.sh``, and deliberately keeps
|
|
804
|
+
its unit set: bytes, KB, MB, GB. Terabytes are NOT accepted - the shell
|
|
805
|
+
version rejected them, and silently widening the accepted range of a flag
|
|
806
|
+
that drives ``rm`` is how ``--size=1t`` turns into a deleted library.
|
|
807
|
+
|
|
808
|
+
Binary units throughout (KB == KiB == 1024), as in the shell version.
|
|
809
|
+
Unlike the shell version, a leading zero is decimal, not octal (``010mb``
|
|
810
|
+
is 10 MB, not 8 MB), and a signed value is rejected outright.
|
|
811
|
+
"""
|
|
812
|
+
cleaned = "".join(text.split()).lower()
|
|
813
|
+
match = _SIZE_RE.fullmatch(cleaned)
|
|
814
|
+
if match is None:
|
|
815
|
+
raise ValueError(f"Invalid size: {text!r}")
|
|
816
|
+
|
|
817
|
+
digits, unit = match.groups()
|
|
818
|
+
|
|
819
|
+
multipliers = {
|
|
820
|
+
"": 1,
|
|
821
|
+
"b": 1,
|
|
822
|
+
"k": _KB,
|
|
823
|
+
"kb": _KB,
|
|
824
|
+
"kib": _KB,
|
|
825
|
+
"m": _MB,
|
|
826
|
+
"mb": _MB,
|
|
827
|
+
"mib": _MB,
|
|
828
|
+
"g": _GB,
|
|
829
|
+
"gb": _GB,
|
|
830
|
+
"gib": _GB,
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
if unit not in multipliers:
|
|
834
|
+
raise ValueError(f"Invalid size unit in {text!r}")
|
|
835
|
+
|
|
836
|
+
return int(digits) * multipliers[unit]
|