@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.
Files changed (53) hide show
  1. package/README.md +65 -25
  2. package/bin/art.py +123 -0
  3. package/bin/che.py +209 -568
  4. package/bin/commands.py +220 -231
  5. package/bin/doctor.py +220 -0
  6. package/bin/drives/eject-all-drives.py +21 -38
  7. package/bin/drives/mount-all-drives.py +18 -62
  8. package/bin/drives/ping-nas.py +77 -105
  9. package/bin/files/compress-folders.py +7 -11
  10. package/bin/files/delete-by-ext.py +10 -14
  11. package/bin/files/delete-empty-folders.py +33 -37
  12. package/bin/files/delete-smb-files.py +12 -21
  13. package/bin/files/files-under-size.py +5 -11
  14. package/bin/files/find-largest-files.py +21 -25
  15. package/bin/files/list-permission.py +40 -0
  16. package/bin/files/make-alpha-dir.py +2 -0
  17. package/bin/git/all-actions.py +350 -253
  18. package/bin/git/checkout-my-branches.py +42 -63
  19. package/bin/git/clean-stale-branches.py +23 -48
  20. package/bin/git/prune-worktrees.py +12 -51
  21. package/bin/git/sync-all-branches.py +54 -283
  22. package/bin/git/update-local-branches.py +67 -73
  23. package/bin/gitutils.py +263 -0
  24. package/bin/install.py +393 -1188
  25. package/bin/machine.py +345 -0
  26. package/bin/media.py +127 -0
  27. package/bin/menu.py +437 -0
  28. package/bin/nas.py +102 -0
  29. package/bin/rcfile.py +380 -0
  30. package/bin/shellgen.py +508 -428
  31. package/bin/system/btop-launch.py +17 -13
  32. package/bin/system/update-brew.py +57 -68
  33. package/bin/tui.py +156 -153
  34. package/bin/utils.py +165 -237
  35. package/bin/video/delete-duplicate-videos.py +64 -62
  36. package/bin/video/detect-green-magenta-videos.py +105 -95
  37. package/bin/video/find-movie-by-year.py +3 -0
  38. package/bin/video/find-video-mkv-issues.py +48 -65
  39. package/bin/video/fix-codecs.py +25 -30
  40. package/bin/video/largest-tv-shows.py +60 -57
  41. package/bin/video/remove-metadata.py +3 -6
  42. package/bin/video/rename-video-file.py +74 -84
  43. package/bin/video/scan-videos-audio-language.py +16 -33
  44. package/bin/video/show-codecs.py +20 -24
  45. package/bin/video/validate-video-files.py +5 -8
  46. package/bin/video/video-list.py +17 -20
  47. package/package.json +4 -10
  48. package/shell/che.bash +33 -33
  49. package/shell/che.fish +7 -7
  50. package/shell/che.zsh +33 -33
  51. package/shell/completions/_che +19 -19
  52. package/shell/completions/che.bash +15 -15
  53. 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