@codebend3r/che 1.1.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 +38 -24
- package/bin/art.py +22 -20
- package/bin/che.py +208 -587
- 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 -1182
- 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 +503 -431
- 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/rcfile.py
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
"""Text surgery on the user's rc files: the managed block and the legacy wrappers.
|
|
2
|
+
|
|
3
|
+
Every edit is a pure function from text to text - :func:`with_block`,
|
|
4
|
+
:func:`without_block`, :func:`without_legacy` - so a caller reads a file once,
|
|
5
|
+
chains whatever edits it needs, and writes the result once through
|
|
6
|
+
:func:`commit_rc`, which backs the file up first. One write per file is also
|
|
7
|
+
one backup per file, so the backup always holds the file as it was before che
|
|
8
|
+
touched it.
|
|
9
|
+
|
|
10
|
+
Library module: no shebang, not executable.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import NamedTuple
|
|
21
|
+
|
|
22
|
+
import shellgen
|
|
23
|
+
from commands import wrapper_names
|
|
24
|
+
from machine import backup_dir
|
|
25
|
+
from utils import atomic_write_text
|
|
26
|
+
|
|
27
|
+
BEGIN = shellgen.BEGIN_MARKER
|
|
28
|
+
END = shellgen.END_MARKER
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Reading and writing
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def read_rc(path: Path) -> str:
|
|
37
|
+
"""``path``'s text, or "" when it does not exist yet.
|
|
38
|
+
|
|
39
|
+
Bytes that are not UTF-8 (a Latin-1 comment, one stray byte) decode to
|
|
40
|
+
surrogates and :func:`commit_rc` writes them back unchanged. Treating them
|
|
41
|
+
as an unreadable file would hand "" to the next edit, which then writes the
|
|
42
|
+
che block over everything the user had. Any other failure - permissions, a
|
|
43
|
+
directory where the file should be - is raised rather than read as empty.
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
return path.read_text(encoding="utf-8", errors="surrogateescape")
|
|
47
|
+
except FileNotFoundError:
|
|
48
|
+
return ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def backup_file(path: Path) -> None:
|
|
52
|
+
"""Copy ``path`` into the backup directory, under a name no backup has yet."""
|
|
53
|
+
if not path.exists():
|
|
54
|
+
return
|
|
55
|
+
stem = f"{path.name.lstrip('.')}.{datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
|
56
|
+
target = backup_dir() / f"{stem}.bak"
|
|
57
|
+
counter = 0
|
|
58
|
+
while target.exists():
|
|
59
|
+
counter += 1
|
|
60
|
+
target = backup_dir() / f"{stem}-{counter}.bak"
|
|
61
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
shutil.copy2(path, target)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def commit_rc(path: Path, original: str, updated: str, *, dry_run: bool = False) -> bool:
|
|
66
|
+
"""Write ``updated`` over ``path``, which read as ``original``, backing it up first.
|
|
67
|
+
|
|
68
|
+
Returns whether that changes anything; a dry run answers without writing.
|
|
69
|
+
"""
|
|
70
|
+
if updated == original:
|
|
71
|
+
return False
|
|
72
|
+
if not dry_run:
|
|
73
|
+
backup_file(path)
|
|
74
|
+
atomic_write_text(path, updated)
|
|
75
|
+
return True
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
# The managed block
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def find_block(text: str) -> tuple[int, int] | None:
|
|
84
|
+
"""Character span of the managed block, or None when it is not there."""
|
|
85
|
+
start = text.find(BEGIN)
|
|
86
|
+
if start < 0:
|
|
87
|
+
return None
|
|
88
|
+
end = text.find(END, start)
|
|
89
|
+
if end < 0:
|
|
90
|
+
# A truncated block (someone deleted the end marker): treat everything
|
|
91
|
+
# from the start marker to end of file as ours rather than appending a
|
|
92
|
+
# second copy that would never be found again.
|
|
93
|
+
return start, len(text)
|
|
94
|
+
return start, end + len(END)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def with_block(text: str, block: str) -> tuple[str, str]:
|
|
98
|
+
"""``text`` with the managed block inserted or replaced, and what happened.
|
|
99
|
+
|
|
100
|
+
The action is one of ``created``, ``updated``, ``unchanged``. The block goes
|
|
101
|
+
at the *end* of the file on a fresh install so that its definitions win
|
|
102
|
+
over anything defined earlier, and stays exactly where it is on an update
|
|
103
|
+
so a user who moved it keeps their placement.
|
|
104
|
+
"""
|
|
105
|
+
span = find_block(text)
|
|
106
|
+
|
|
107
|
+
if span is None:
|
|
108
|
+
separator = "" if not text or text.endswith("\n\n") else "\n"
|
|
109
|
+
if text and not text.endswith("\n"):
|
|
110
|
+
separator = "\n\n"
|
|
111
|
+
return f"{text}{separator}{block}", "created"
|
|
112
|
+
|
|
113
|
+
start, end = span
|
|
114
|
+
if text[start:end].rstrip() == block.rstrip():
|
|
115
|
+
return text, "unchanged"
|
|
116
|
+
return text[:start] + block.rstrip("\n") + text[end:], "updated"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def without_block(text: str) -> str:
|
|
120
|
+
"""``text`` with the managed block taken back out."""
|
|
121
|
+
span = find_block(text)
|
|
122
|
+
if span is None:
|
|
123
|
+
return text
|
|
124
|
+
start, end = span
|
|
125
|
+
return text[:start].rstrip("\n") + "\n" + text[end:].lstrip("\n")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _managed_lines(text: str) -> range:
|
|
129
|
+
"""Line indices the managed block covers, so the legacy scan never enters it."""
|
|
130
|
+
span = find_block(text)
|
|
131
|
+
if span is None:
|
|
132
|
+
return range(0)
|
|
133
|
+
start, end = span
|
|
134
|
+
return range(text.count("\n", 0, start), text.count("\n", 0, end) + 1)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
# Legacy hand-written wrappers
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
LEGACY_VARIABLE = "SHELL_FUNCTIONS_BIN"
|
|
142
|
+
|
|
143
|
+
_ASSIGNMENT = re.compile(rf"^(?:export\s+)?{LEGACY_VARIABLE}=")
|
|
144
|
+
_ALIAS = re.compile(r"^alias\s+([A-Za-z0-9_.-]+)=")
|
|
145
|
+
# The start of a function definition on a stripped line: `name() {`,
|
|
146
|
+
# `function name() {` or `name {`. Group 1 is the name, group 2 the
|
|
147
|
+
# parentheses when there are any, group 3 whatever follows the brace.
|
|
148
|
+
_FUNCTION_HEAD = re.compile(r"^(?:function\s+)?([A-Za-z0-9_.-]+)\s*(\(\))?\s*\{(.*)")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True)
|
|
152
|
+
class LegacyBlock:
|
|
153
|
+
"""One hand-written wrapper (or the helper behind it) found in an rc file."""
|
|
154
|
+
|
|
155
|
+
start: int
|
|
156
|
+
end: int
|
|
157
|
+
name: str
|
|
158
|
+
body: str
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class Span(NamedTuple):
|
|
162
|
+
"""Lines ``start`` up to ``end`` hold one legacy definition, its comment included."""
|
|
163
|
+
|
|
164
|
+
start: int
|
|
165
|
+
end: int
|
|
166
|
+
name: str # the function's name, or "" for the SHELL_FUNCTIONS_BIN assignment
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass(frozen=True)
|
|
170
|
+
class LegacyScan:
|
|
171
|
+
"""What one pass over an rc file learns about the legacy wrappers in it."""
|
|
172
|
+
|
|
173
|
+
lines: list[str]
|
|
174
|
+
managed: range # line indices of the che block, which is never touched
|
|
175
|
+
spans: list[Span]
|
|
176
|
+
helpers: set[str]
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def names(self) -> list[str]:
|
|
180
|
+
return [span.name for span in self.spans if span.name]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def find_legacy(text: str) -> list[str]:
|
|
184
|
+
"""Names of hand-written wrappers in ``text`` that che now generates.
|
|
185
|
+
|
|
186
|
+
Before the installer existed, the documented workflow was to copy the
|
|
187
|
+
repo's ``.zshrc`` wrappers into ``~/.zshrc`` by hand, so most machines have
|
|
188
|
+
a few dozen of them sitting there. They are harmless - the managed block is
|
|
189
|
+
appended after, so its definitions win - but they go stale, which is why
|
|
190
|
+
doctor reports them and ``--replace-legacy`` removes them.
|
|
191
|
+
"""
|
|
192
|
+
return _legacy_scan(text).names
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _function_blocks(lines: list[str], managed: range) -> list[LegacyBlock]:
|
|
196
|
+
"""Every complete multi-line ``name() { … }`` outside the managed block."""
|
|
197
|
+
blocks: list[LegacyBlock] = []
|
|
198
|
+
index = 0
|
|
199
|
+
|
|
200
|
+
while index < len(lines):
|
|
201
|
+
head = None if index in managed else _FUNCTION_HEAD.match(lines[index].strip())
|
|
202
|
+
# Only a `name() {` that ends its line opens a body the brace count
|
|
203
|
+
# below can follow; a one-liner is left alone.
|
|
204
|
+
if not head or not head.group(2) or head.group(3):
|
|
205
|
+
index += 1
|
|
206
|
+
continue
|
|
207
|
+
|
|
208
|
+
depth = 1
|
|
209
|
+
cursor = index + 1
|
|
210
|
+
body: list[str] = []
|
|
211
|
+
while cursor < len(lines) and depth > 0:
|
|
212
|
+
depth += lines[cursor].count("{") - lines[cursor].count("}")
|
|
213
|
+
body.append(lines[cursor])
|
|
214
|
+
cursor += 1
|
|
215
|
+
|
|
216
|
+
if depth == 0:
|
|
217
|
+
blocks.append(LegacyBlock(index, cursor, head.group(1), "\n".join(body)))
|
|
218
|
+
index = cursor
|
|
219
|
+
continue
|
|
220
|
+
|
|
221
|
+
index += 1
|
|
222
|
+
|
|
223
|
+
return blocks
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _legacy_scan(text: str) -> LegacyScan:
|
|
227
|
+
"""Line spans holding legacy wrappers, plus the helper names among them.
|
|
228
|
+
|
|
229
|
+
Seeded with everything that mentions ``SHELL_FUNCTIONS_BIN`` directly, then
|
|
230
|
+
grown: a function that calls a legacy *helper* is legacy too. That second
|
|
231
|
+
step is what handles a table-driven rc file, where the wrappers are built
|
|
232
|
+
by an ``_sf`` helper that itself only calls ``_sf_run``.
|
|
233
|
+
|
|
234
|
+
Growth deliberately stops at helpers. A function of the user's own that
|
|
235
|
+
happens to call ``sync-all-branches`` is their automation, not a wrapper,
|
|
236
|
+
and must survive untouched.
|
|
237
|
+
"""
|
|
238
|
+
lines = text.splitlines()
|
|
239
|
+
managed = _managed_lines(text)
|
|
240
|
+
blocks = _function_blocks(lines, managed)
|
|
241
|
+
generated = set(wrapper_names())
|
|
242
|
+
|
|
243
|
+
marked = {block for block in blocks if LEGACY_VARIABLE in block.body}
|
|
244
|
+
helpers = {block.name for block in marked if block.name not in generated}
|
|
245
|
+
|
|
246
|
+
while True:
|
|
247
|
+
grown = {
|
|
248
|
+
block
|
|
249
|
+
for block in blocks
|
|
250
|
+
if block not in marked and any(helper in block.body for helper in helpers)
|
|
251
|
+
}
|
|
252
|
+
if not grown:
|
|
253
|
+
break
|
|
254
|
+
marked |= grown
|
|
255
|
+
helpers |= {block.name for block in grown if block.name not in generated}
|
|
256
|
+
|
|
257
|
+
spans = [Span(_extend_upwards(lines, block.start), block.end, block.name) for block in marked]
|
|
258
|
+
spans += [
|
|
259
|
+
Span(_extend_upwards(lines, index), index + 1, "")
|
|
260
|
+
for index, line in enumerate(lines)
|
|
261
|
+
if index not in managed and _ASSIGNMENT.match(line.strip())
|
|
262
|
+
]
|
|
263
|
+
return LegacyScan(lines, managed, sorted(spans), helpers)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _extend_upwards(lines: list[str], start: int) -> int:
|
|
267
|
+
"""Pull the comment block that documents a wrapper into its span.
|
|
268
|
+
|
|
269
|
+
Every wrapper in the old ``.zshrc`` had a banner or a sentence above it.
|
|
270
|
+
Removing the function but leaving its comment behind produces a file full
|
|
271
|
+
of headings with nothing under them, so the span starts at the first line
|
|
272
|
+
of the attached comment instead. The walk stops at the first line that is
|
|
273
|
+
neither a comment nor the single blank line between a banner and what it
|
|
274
|
+
introduces, so a comment belonging to code that stays is never taken.
|
|
275
|
+
"""
|
|
276
|
+
cursor = start
|
|
277
|
+
if cursor > 0 and not lines[cursor - 1].strip():
|
|
278
|
+
cursor -= 1
|
|
279
|
+
while cursor > 0 and lines[cursor - 1].lstrip().startswith("#"):
|
|
280
|
+
cursor -= 1
|
|
281
|
+
return cursor if lines[cursor].lstrip().startswith("#") else start
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _consecutive_groups(indices: list[int]) -> list[list[int]]:
|
|
285
|
+
groups: list[list[int]] = []
|
|
286
|
+
for index in sorted(indices):
|
|
287
|
+
if groups and index == groups[-1][-1] + 1:
|
|
288
|
+
groups[-1].append(index)
|
|
289
|
+
else:
|
|
290
|
+
groups.append([index])
|
|
291
|
+
return groups
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def without_legacy(text: str) -> tuple[str, list[str]]:
|
|
295
|
+
"""``text`` with its legacy wrappers removed, and the names that went.
|
|
296
|
+
|
|
297
|
+
Two passes. The first drops the ``SHELL_FUNCTIONS_BIN`` assignment, every
|
|
298
|
+
function that used it, and the helpers those functions were built from,
|
|
299
|
+
along with the comment documenting each. The second drops the lines that
|
|
300
|
+
*call* a removed helper - which is what makes a table-driven ``~/.zshrc``
|
|
301
|
+
safe to clean: removing an ``_sf`` helper while leaving its forty
|
|
302
|
+
``_sf <name> …`` lines behind would turn every new shell into forty
|
|
303
|
+
"command not found" errors.
|
|
304
|
+
|
|
305
|
+
Calls to removed *wrappers* are never touched, only calls to helpers. A
|
|
306
|
+
line of the user's own that runs ``mount-all-drives`` at login is theirs.
|
|
307
|
+
"""
|
|
308
|
+
scan = _legacy_scan(text)
|
|
309
|
+
if not scan.spans:
|
|
310
|
+
return text, []
|
|
311
|
+
|
|
312
|
+
lines = scan.lines
|
|
313
|
+
drop: set[int] = set()
|
|
314
|
+
for span in scan.spans:
|
|
315
|
+
drop.update(range(span.start, span.end))
|
|
316
|
+
|
|
317
|
+
callers = [
|
|
318
|
+
index
|
|
319
|
+
for index, line in enumerate(lines)
|
|
320
|
+
if index not in drop
|
|
321
|
+
and index not in scan.managed
|
|
322
|
+
and line.strip().split(" ")[0] in scan.helpers
|
|
323
|
+
]
|
|
324
|
+
for group in _consecutive_groups(callers):
|
|
325
|
+
drop.update(range(_extend_upwards(lines, group[0]), group[-1] + 1))
|
|
326
|
+
|
|
327
|
+
# Collapse the runs of blank lines the removal leaves behind, so the rc
|
|
328
|
+
# file does not end up with a hole in it.
|
|
329
|
+
tidied: list[str] = []
|
|
330
|
+
for index, line in enumerate(lines):
|
|
331
|
+
if index in drop or (not line.strip() and tidied and not tidied[-1].strip()):
|
|
332
|
+
continue
|
|
333
|
+
tidied.append(line)
|
|
334
|
+
|
|
335
|
+
return "\n".join(tidied).rstrip("\n") + "\n", scan.names
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# ---------------------------------------------------------------------------
|
|
339
|
+
# Shadowing
|
|
340
|
+
# ---------------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
# How a name shadows a wrapper, as find_shadowing reports it.
|
|
343
|
+
ALIAS = "alias"
|
|
344
|
+
REDEFINED = "redefined after the block"
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def find_shadowing(text: str) -> list[tuple[str, str]]:
|
|
348
|
+
"""Names in ``text`` that would win over a generated wrapper.
|
|
349
|
+
|
|
350
|
+
Two ways that happens, both silent: an alias of the same name (in zsh and
|
|
351
|
+
bash an alias is resolved before a function, so `alias remove-metadata=...`
|
|
352
|
+
makes the wrapper dead code), and a function redefined *after* the managed
|
|
353
|
+
block, since the last definition wins. Definitions before the block are
|
|
354
|
+
fine, so they are not reported.
|
|
355
|
+
"""
|
|
356
|
+
names = set(wrapper_names())
|
|
357
|
+
span = find_block(text)
|
|
358
|
+
block_end = span[1] if span else len(text)
|
|
359
|
+
|
|
360
|
+
found: list[tuple[str, str]] = []
|
|
361
|
+
offset = 0
|
|
362
|
+
for line in text.splitlines(keepends=True):
|
|
363
|
+
stripped = line.strip()
|
|
364
|
+
start_of_line, offset = offset, offset + len(line)
|
|
365
|
+
|
|
366
|
+
alias = _ALIAS.match(stripped)
|
|
367
|
+
if alias and alias.group(1) in names:
|
|
368
|
+
found.append((alias.group(1), ALIAS))
|
|
369
|
+
continue
|
|
370
|
+
|
|
371
|
+
if start_of_line <= block_end:
|
|
372
|
+
# Defined before or inside our block: ours is sourced later and
|
|
373
|
+
# wins, so there is nothing to report.
|
|
374
|
+
continue
|
|
375
|
+
|
|
376
|
+
definition = _FUNCTION_HEAD.match(stripped)
|
|
377
|
+
if definition and definition.group(1) in names:
|
|
378
|
+
found.append((definition.group(1), REDEFINED))
|
|
379
|
+
|
|
380
|
+
return found
|