@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/tui.py CHANGED
@@ -22,6 +22,7 @@ Library module: no shebang, not executable.
22
22
 
23
23
  from __future__ import annotations
24
24
 
25
+ import functools
25
26
  import os
26
27
  import re
27
28
  import select
@@ -30,11 +31,11 @@ import sys
30
31
  import termios
31
32
  import tty
32
33
  import unicodedata
33
- from collections.abc import Iterable, Sequence
34
+ from collections.abc import Callable, Iterable, Iterator, Sequence
34
35
  from contextlib import suppress
35
36
  from dataclasses import dataclass
36
37
  from pathlib import Path
37
- from types import TracebackType
38
+ from types import ModuleType, TracebackType
38
39
 
39
40
  from utils import color_enabled
40
41
 
@@ -49,28 +50,14 @@ class Palette:
49
50
 
50
51
  reset: str = "\033[0m"
51
52
  bold: str = "\033[1m"
52
- dim: str = "\033[2m"
53
- italic: str = "\033[3m"
54
- underline: str = "\033[4m"
55
- reverse: str = "\033[7m"
56
53
 
57
54
  red: str = "\033[31m"
58
55
  green: str = "\033[32m"
59
56
  yellow: str = "\033[33m"
60
57
  blue: str = "\033[34m"
61
- magenta: str = "\033[35m"
62
58
  cyan: str = "\033[36m"
63
- white: str = "\033[37m"
64
59
  grey: str = "\033[90m"
65
-
66
60
  bright_magenta: str = "\033[95m"
67
- bright_cyan: str = "\033[96m"
68
- bright_green: str = "\033[92m"
69
- bright_yellow: str = "\033[93m"
70
- bright_red: str = "\033[91m"
71
-
72
- on_magenta: str = "\033[45m"
73
- on_grey: str = "\033[100m"
74
61
 
75
62
 
76
63
  PLAIN = Palette(**{field: "" for field in Palette.__dataclass_fields__})
@@ -92,39 +79,60 @@ def strip_ansi(text: str) -> str:
92
79
  # Width
93
80
  # ---------------------------------------------------------------------------
94
81
 
95
- _VARIATION_SELECTOR_16 = "️"
82
+ _VARIATION_SELECTOR_16 = "\ufe0f"
96
83
 
97
84
 
98
- def display_width(text: str) -> int:
99
- """Columns ``text`` occupies, ignoring escape sequences.
85
+ def _is_modifier(char: str) -> bool:
86
+ """Takes no columns of its own: U+FE0F or a combining mark.
100
87
 
101
- ``len()`` is wrong for the two things that show up in this UI: colour
102
- escapes (zero width) and emoji (two columns). Getting it wrong misaligns
103
- every row after it, so the menu measures with this instead.
88
+ U+FE0F only asks for the emoji presentation of the character before it.
89
+ Counting it as one column was worth two columns of drift per row on 🗑️.
104
90
  """
105
- chars = list(strip_ansi(text))
106
- width = 0
107
-
108
- for index, char in enumerate(chars):
109
- if char == _VARIATION_SELECTOR_16 or unicodedata.combining(char):
110
- # U+FE0F only asks for the emoji presentation of the character
111
- # before it; it takes no columns of its own. Counting it as one
112
- # was worth two columns of drift per row on 🗑️ and friends.
113
- continue
91
+ return char == _VARIATION_SELECTOR_16 or bool(unicodedata.combining(char))
114
92
 
115
- if unicodedata.east_asian_width(char) in ("W", "F"):
116
- width += 2
117
- elif ord(char) >= 0x1F300:
93
+
94
+ def _clusters(text: str) -> Iterator[tuple[str, int]]:
95
+ """``text`` without escapes, as (character plus its modifiers, columns) pairs.
96
+
97
+ ``display_width`` and ``truncate`` both measure through this, so a cut can
98
+ never land between an emoji and the selector that makes it two columns.
99
+ """
100
+ chars = strip_ansi(text)
101
+ index = 0
102
+ while index < len(chars):
103
+ end = index + 1
104
+ while end < len(chars) and _is_modifier(chars[end]):
105
+ end += 1
106
+ cluster = chars[index:end]
107
+ base = cluster[0]
108
+
109
+ if _is_modifier(base):
110
+ # A modifier with nothing before it to modify.
111
+ width = 0
112
+ elif unicodedata.east_asian_width(base) in ("W", "F"):
113
+ width = 2
114
+ elif ord(base) >= 0x1F300:
118
115
  # Emoji that Unicode still classifies as neutral (🗑, 🎞) but every
119
116
  # terminal draws double width.
120
- width += 2
121
- elif index + 1 < len(chars) and chars[index + 1] == _VARIATION_SELECTOR_16:
117
+ width = 2
118
+ elif cluster[1:2] == _VARIATION_SELECTOR_16:
122
119
  # A narrow symbol promoted to emoji presentation (⌨️, ⬆️).
123
- width += 2
120
+ width = 2
124
121
  else:
125
- width += 1
122
+ width = 1
123
+
124
+ yield cluster, width
125
+ index = end
126
+
127
+
128
+ def display_width(text: str) -> int:
129
+ """Columns ``text`` occupies, ignoring escape sequences.
126
130
 
127
- return width
131
+ ``len()`` is wrong for the two things that show up in this UI: colour
132
+ escapes (zero width) and emoji (two columns). Getting it wrong misaligns
133
+ every row after it, so the menu measures with this instead.
134
+ """
135
+ return sum(width for _, width in _clusters(text))
128
136
 
129
137
 
130
138
  def truncate(text: str, width: int, *, ellipsis: str = "…") -> str:
@@ -137,11 +145,10 @@ def truncate(text: str, width: int, *, ellipsis: str = "…") -> str:
137
145
  budget = width - display_width(ellipsis)
138
146
  out: list[str] = []
139
147
  used = 0
140
- for char in strip_ansi(text):
141
- step = display_width(char)
148
+ for cluster, step in _clusters(text):
142
149
  if used + step > budget:
143
150
  break
144
- out.append(char)
151
+ out.append(cluster)
145
152
  used += step
146
153
  return "".join(out) + ellipsis
147
154
 
@@ -167,7 +174,6 @@ ALT_SCREEN_ON = "\033[?1049h"
167
174
  ALT_SCREEN_OFF = "\033[?1049l"
168
175
  CURSOR_HIDE = "\033[?25l"
169
176
  CURSOR_SHOW = "\033[?25h"
170
- CLEAR = "\033[H\033[2J"
171
177
 
172
178
 
173
179
  def size() -> tuple[int, int]:
@@ -191,8 +197,9 @@ class Term:
191
197
  """Alternate screen + raw input, restored no matter how the block exits.
192
198
 
193
199
  A TUI that leaves the terminal in raw mode with the cursor hidden is worse
194
- than no TUI at all, so teardown runs from ``__exit__`` *and* from handlers
195
- for SIGINT/SIGTERM/SIGHUP, and is written to be safe to call twice.
200
+ than no TUI at all, so teardown runs from ``__exit__`` (which an exception,
201
+ Ctrl-C's KeyboardInterrupt included, also reaches) *and* from handlers for
202
+ SIGTERM/SIGHUP, and is written to be safe to call twice.
196
203
  """
197
204
 
198
205
  def __init__(self, *, alt_screen: bool = True) -> None:
@@ -201,17 +208,13 @@ class Term:
201
208
  self._saved: list | None = None
202
209
  self._active = False
203
210
  self._previous_handlers: dict[int, object] = {}
204
- self.resized = False
205
211
 
206
212
  # -- lifecycle ------------------------------------------------------
207
213
  def __enter__(self) -> Term:
208
214
  self.fd = sys.stdin.fileno()
209
215
  with suppress(termios.error, ValueError):
210
216
  self._saved = termios.tcgetattr(self.fd)
211
- tty.setraw(self.fd)
212
-
213
- if self.alt_screen:
214
- self._write(ALT_SCREEN_ON + CURSOR_HIDE)
217
+ self.resume()
215
218
 
216
219
  self._active = True
217
220
  self._install_handlers()
@@ -229,13 +232,7 @@ class Term:
229
232
  if not self._active:
230
233
  return
231
234
  self._active = False
232
-
233
- if self.alt_screen:
234
- self._write(CURSOR_SHOW + ALT_SCREEN_OFF)
235
- if self._saved is not None:
236
- with suppress(termios.error, ValueError):
237
- termios.tcsetattr(self.fd, termios.TCSADRAIN, self._saved)
238
-
235
+ self.suspend()
239
236
  self._restore_handlers()
240
237
 
241
238
  # -- suspending, for running a child command in the normal screen ----
@@ -256,9 +253,6 @@ class Term:
256
253
 
257
254
  # -- signals --------------------------------------------------------
258
255
  def _install_handlers(self) -> None:
259
- def on_resize(_signum: int, _frame: object) -> None:
260
- self.resized = True
261
-
262
256
  def on_kill(signum: int, _frame: object) -> None:
263
257
  self.close()
264
258
  # Re-raise with the default disposition so the exit status is the
@@ -266,10 +260,6 @@ class Term:
266
260
  signal.signal(signum, signal.SIG_DFL)
267
261
  os.kill(os.getpid(), signum)
268
262
 
269
- for signum, handler in ((signal.SIGWINCH, on_resize),):
270
- with suppress(ValueError, OSError):
271
- self._previous_handlers[signum] = signal.signal(signum, handler)
272
-
273
263
  for signum in (signal.SIGTERM, signal.SIGHUP):
274
264
  with suppress(ValueError, OSError):
275
265
  self._previous_handlers[signum] = signal.signal(signum, on_kill)
@@ -292,6 +282,17 @@ class Term:
292
282
  # Input
293
283
  # ---------------------------------------------------------------------------
294
284
 
285
+ _CONTROL_KEYS = {
286
+ "\x03": "ctrl-c",
287
+ "\x04": "eof",
288
+ "\r": "enter",
289
+ "\n": "enter",
290
+ "\t": "tab",
291
+ "\x7f": "backspace",
292
+ "\b": "backspace",
293
+ "\x15": "ctrl-u",
294
+ "\x17": "ctrl-w",
295
+ }
295
296
  _CSI_KEYS = {
296
297
  "A": "up",
297
298
  "B": "down",
@@ -312,12 +313,23 @@ _CSI_TILDE_KEYS = {
312
313
  }
313
314
 
314
315
 
316
+ def _utf8_tail(lead: int) -> int:
317
+ """How many continuation bytes follow ``lead`` in a UTF-8 sequence."""
318
+ if 0xC0 <= lead < 0xE0:
319
+ return 1
320
+ if 0xE0 <= lead < 0xF0:
321
+ return 2
322
+ if 0xF0 <= lead < 0xF8:
323
+ return 3
324
+ return 0
325
+
326
+
315
327
  def read_key(timeout: float | None = None) -> str | None:
316
328
  """Read one keypress in raw mode and return a name for it.
317
329
 
318
- Returns ``None`` on timeout (used so a SIGWINCH can redraw a frame that is
319
- otherwise blocked in ``read``). Unknown escape sequences are swallowed
320
- rather than leaking their bytes into a filter box.
330
+ Returns ``None`` on timeout, which lets the menu redraw for a resize while
331
+ no key is pressed. Unknown escape sequences are swallowed rather than
332
+ leaking their bytes into a filter box.
321
333
  """
322
334
  fd = sys.stdin.fileno()
323
335
 
@@ -327,36 +339,25 @@ def read_key(timeout: float | None = None) -> str | None:
327
339
  return None
328
340
 
329
341
  try:
330
- first = os.read(fd, 1).decode("utf-8", "replace")
331
- except (OSError, InterruptedError):
342
+ raw = os.read(fd, 1)
343
+ except OSError:
332
344
  return None
333
- if not first:
345
+ if not raw:
334
346
  return "eof"
335
347
 
336
- if first == "\x03":
337
- return "ctrl-c"
338
- if first == "\x04":
339
- return "eof"
340
- if first in ("\r", "\n"):
341
- return "enter"
342
- if first == "\t":
343
- return "tab"
344
- if first in ("\x7f", "\b"):
345
- return "backspace"
346
- if first == "\x15":
347
- return "ctrl-u"
348
- if first == "\x17":
349
- return "ctrl-w"
348
+ # A multi-byte UTF-8 character arrives one byte at a time. Its lead byte
349
+ # says exactly how many more belong to it, so the next keystroke is never
350
+ # read along with it.
351
+ for _ in range(_utf8_tail(raw[0])):
352
+ ready, _, _ = select.select([fd], [], [], 0.05)
353
+ if not ready:
354
+ break
355
+ raw += os.read(fd, 1)
356
+ first = raw.decode("utf-8", "replace")
357
+
358
+ if first in _CONTROL_KEYS:
359
+ return _CONTROL_KEYS[first]
350
360
  if first != "\033":
351
- # A multi-byte UTF-8 character arrives one byte at a time.
352
- if ord(first[0]) >= 0x80:
353
- extra = b""
354
- for _ in range(3):
355
- ready, _, _ = select.select([fd], [], [], 0.01)
356
- if not ready:
357
- break
358
- extra += os.read(fd, 1)
359
- return (first.encode("utf-8", "surrogateescape") + extra).decode("utf-8", "replace")
360
361
  return first
361
362
 
362
363
  # Escape, possibly introducing a sequence. A lone Escape has nothing
@@ -392,12 +393,11 @@ class Screen:
392
393
  """Compose a frame in memory, then paint it in one write."""
393
394
 
394
395
  def __init__(self) -> None:
395
- self.columns, self.rows = size()
396
- self._lines: list[str] = []
396
+ self.start()
397
397
 
398
398
  def start(self) -> None:
399
399
  self.columns, self.rows = size()
400
- self._lines = []
400
+ self._lines: list[str] = []
401
401
 
402
402
  def line(self, text: str = "") -> None:
403
403
  self._lines.append(truncate(text, self.columns))
@@ -428,8 +428,9 @@ class Screen:
428
428
  # ---------------------------------------------------------------------------
429
429
 
430
430
 
431
- def _setup_readline(history_file: Path | None = None) -> object | None:
432
- """Enable editing, filename completion and history for ``input()``.
431
+ @functools.cache
432
+ def _readline() -> ModuleType | None:
433
+ """The readline module, set up once for editing and filename completion.
433
434
 
434
435
  macOS ships readline as libedit, which needs a different bind syntax; both
435
436
  are handled. A missing readline module is not fatal - the prompt just loses
@@ -440,21 +441,30 @@ def _setup_readline(history_file: Path | None = None) -> object | None:
440
441
  except ImportError:
441
442
  return None
442
443
 
443
- if "libedit" in (getattr(readline, "__doc__", "") or ""):
444
+ if "libedit" in (readline.__doc__ or ""):
444
445
  readline.parse_and_bind("bind ^I rl_complete")
445
446
  else:
446
447
  readline.parse_and_bind("tab: complete")
447
448
 
448
449
  readline.set_completer_delims(" \t\n=")
450
+ return readline
449
451
 
450
- if history_file is not None:
451
- with suppress(OSError):
452
- history_file.parent.mkdir(parents=True, exist_ok=True)
453
- if history_file.exists():
454
- readline.read_history_file(history_file)
455
- readline.set_history_length(200)
456
452
 
457
- return readline
453
+ def _load_history(readline: ModuleType, history_file: Path | None) -> None:
454
+ """Make ``history_file`` the only history the next ``input()`` offers.
455
+
456
+ ``read_history_file`` appends to what is already in memory, so without the
457
+ clear every prompt would offer every earlier prompt's answers - and save
458
+ them all into its own file.
459
+ """
460
+ readline.clear_history()
461
+ if history_file is None:
462
+ return
463
+ with suppress(OSError):
464
+ history_file.parent.mkdir(parents=True, exist_ok=True)
465
+ if history_file.exists():
466
+ readline.read_history_file(history_file)
467
+ readline.set_history_length(200)
458
468
 
459
469
 
460
470
  def ask(
@@ -467,17 +477,20 @@ def ask(
467
477
  ) -> str:
468
478
  """Ask one question and return the answer, or the default on empty input.
469
479
 
470
- ``kind="path"`` turns on filename completion; ``kind="choice"`` shows the
471
- valid values and re-asks until one of them is given.
480
+ ``kind="path"`` turns on filename completion; ``choices`` shows the valid
481
+ values and re-asks until one of them is given.
472
482
  """
473
483
  colors = palette()
474
- readline = _setup_readline(history_file)
484
+ readline = _readline()
475
485
 
476
486
  if readline is not None:
487
+ _load_history(readline, history_file)
477
488
  if kind == "path":
478
- readline.set_completer(_path_completer)
489
+ readline.set_completer(_completer(_path_matches))
479
490
  elif choices:
480
- readline.set_completer(_choice_completer(choices))
491
+ readline.set_completer(
492
+ _completer(lambda text: [c for c in choices if c.startswith(text)])
493
+ )
481
494
  else:
482
495
  readline.set_completer(lambda *_: None)
483
496
 
@@ -540,12 +553,12 @@ def choose(
540
553
  options: Sequence[tuple[str, str]],
541
554
  *,
542
555
  selected: Iterable[int] = (),
543
- multi: bool = True,
544
556
  ) -> list[int]:
545
- """A checkbox / radio list, driven with the arrow keys.
557
+ """A checkbox list, driven with the arrow keys.
546
558
 
547
- Returns the indices the user accepted. Falls back to a numbered text
548
- prompt when there is no tty, so the wizard still works over a pipe.
559
+ Returns the indices the user accepted. With no tty there is nothing to
560
+ drive, so the preselection comes back unchanged and the wizard still works
561
+ over a pipe.
549
562
  """
550
563
  chosen = set(selected)
551
564
 
@@ -555,7 +568,7 @@ def choose(
555
568
  colors = palette()
556
569
  cursor = min(chosen, default=0)
557
570
 
558
- with Term() as term:
571
+ with Term():
559
572
  screen = Screen()
560
573
  while True:
561
574
  screen.start()
@@ -564,8 +577,6 @@ def choose(
564
577
  screen.line()
565
578
  for index, (label, hint) in enumerate(options):
566
579
  mark = "◉" if index in chosen else "○"
567
- if not multi:
568
- mark = "●" if index in chosen else "○"
569
580
  pointer = f"{colors.cyan}▸{colors.reset}" if index == cursor else " "
570
581
  body = f"{mark} {label}"
571
582
  if index == cursor:
@@ -586,56 +597,48 @@ def choose(
586
597
  elif key in ("down", "j"):
587
598
  cursor = (cursor + 1) % len(options)
588
599
  elif key == " ":
589
- if multi:
590
- chosen.symmetric_difference_update({cursor})
591
- else:
592
- chosen = {cursor}
593
- elif key == "a" and multi:
600
+ chosen.symmetric_difference_update({cursor})
601
+ elif key == "a":
594
602
  chosen = set(range(len(options))) if len(chosen) < len(options) else set()
595
603
  elif key == "enter":
596
- if not chosen and not multi:
597
- chosen = {cursor}
598
- term.close()
599
604
  return sorted(chosen)
600
605
  elif key in ("q", "esc", "ctrl-c"):
601
- term.close()
602
606
  return []
603
607
 
604
608
 
605
- def _path_completer(text: str, state: int) -> str | None:
606
- """readline completer offering filesystem paths, with a / on directories."""
607
- expanded = Path(os.path.expandvars(text)).expanduser()
608
- typed_directory = (
609
- str(expanded.parent) if str(expanded.parent) != "." or text.startswith(".") else ""
610
- )
611
- directory = expanded.parent if str(expanded.parent) else Path()
612
- stem = expanded.name if not text.endswith("/") else ""
613
- if text.endswith("/"):
614
- directory = expanded
615
-
616
- try:
617
- entries = sorted(entry.name for entry in directory.iterdir())
618
- except OSError:
619
- return None
620
-
621
- matches = []
622
- for entry in entries:
623
- if not entry.startswith(stem):
624
- continue
625
- full = str(directory / entry) if typed_directory or text.endswith("/") else entry
626
- matches.append(f"{full}/" if (directory / entry).is_dir() else full)
627
-
628
- return matches[state] if state < len(matches) else None
609
+ def _completer(match: Callable[[str], list[str]]) -> Callable[[str, int], str | None]:
610
+ """A readline completer over ``match(text)``, worked out once per Tab.
629
611
 
612
+ readline asks for match 0, 1, 2, ... until it gets None. Listing the
613
+ matches afresh on every call made one Tab in a folder of a few thousand
614
+ entries on an SMB share quadratic in stats, which reads as a hang.
615
+ """
616
+ matches: list[str] = []
630
617
 
631
- def _choice_completer(choices: Sequence[str]):
632
618
  def complete(text: str, state: int) -> str | None:
633
- matches = [choice for choice in choices if choice.startswith(text)]
619
+ nonlocal matches
620
+ if state == 0:
621
+ matches = match(text)
634
622
  return matches[state] if state < len(matches) else None
635
623
 
636
624
  return complete
637
625
 
638
626
 
627
+ def _path_matches(text: str) -> list[str]:
628
+ """Paths completing ``text`` as typed, ``~`` and all, with a / on directories."""
629
+ head, sep, stem = text.rpartition("/")
630
+ directory = Path(os.path.expandvars(head + sep or ".")).expanduser()
631
+ try:
632
+ with os.scandir(directory) as entries:
633
+ found = sorted(
634
+ (entry for entry in entries if entry.name.startswith(stem)),
635
+ key=lambda entry: entry.name,
636
+ )
637
+ return [f"{head}{sep}{entry.name}{'/' if entry.is_dir() else ''}" for entry in found]
638
+ except OSError:
639
+ return []
640
+
641
+
639
642
  def pause(message: str = "Press any key to continue") -> None:
640
643
  """Wait for a keypress, in raw mode so any key works (not just Enter)."""
641
644
  colors = palette()