@codebend3r/che 1.1.0 → 1.2.1

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 (54) hide show
  1. package/README.md +58 -26
  2. package/bin/art.py +22 -20
  3. package/bin/branchsync.py +324 -0
  4. package/bin/che.py +208 -587
  5. package/bin/commands.py +219 -231
  6. package/bin/doctor.py +220 -0
  7. package/bin/drives/eject-all-drives.py +21 -38
  8. package/bin/drives/mount-all-drives.py +18 -62
  9. package/bin/drives/ping-nas.py +77 -105
  10. package/bin/files/compress-folders.py +7 -11
  11. package/bin/files/delete-by-ext.py +10 -14
  12. package/bin/files/delete-empty-folders.py +33 -37
  13. package/bin/files/delete-smb-files.py +12 -21
  14. package/bin/files/files-under-size.py +5 -11
  15. package/bin/files/find-largest-files.py +21 -25
  16. package/bin/files/list-permission.py +40 -0
  17. package/bin/files/make-alpha-dir.py +2 -0
  18. package/bin/git/all-actions.py +350 -253
  19. package/bin/git/checkout-my-branches.py +42 -63
  20. package/bin/git/clean-stale-branches.py +23 -48
  21. package/bin/git/prune-worktrees.py +12 -51
  22. package/bin/git/sync-all-branches.py +75 -297
  23. package/bin/git/update-local-branches.py +67 -73
  24. package/bin/gitutils.py +263 -0
  25. package/bin/install.py +393 -1182
  26. package/bin/machine.py +345 -0
  27. package/bin/media.py +127 -0
  28. package/bin/menu.py +437 -0
  29. package/bin/nas.py +102 -0
  30. package/bin/rcfile.py +380 -0
  31. package/bin/shellgen.py +503 -431
  32. package/bin/system/btop-launch.py +17 -13
  33. package/bin/system/update-brew.py +57 -68
  34. package/bin/tui.py +156 -153
  35. package/bin/utils.py +165 -237
  36. package/bin/video/delete-duplicate-videos.py +64 -62
  37. package/bin/video/detect-green-magenta-videos.py +105 -95
  38. package/bin/video/find-movie-by-year.py +3 -0
  39. package/bin/video/find-video-mkv-issues.py +48 -65
  40. package/bin/video/fix-codecs.py +25 -30
  41. package/bin/video/largest-tv-shows.py +60 -57
  42. package/bin/video/remove-metadata.py +3 -6
  43. package/bin/video/rename-video-file.py +74 -84
  44. package/bin/video/scan-videos-audio-language.py +16 -33
  45. package/bin/video/show-codecs.py +20 -24
  46. package/bin/video/validate-video-files.py +5 -8
  47. package/bin/video/video-list.py +17 -20
  48. package/package.json +4 -10
  49. package/shell/che.bash +33 -33
  50. package/shell/che.fish +7 -7
  51. package/shell/che.zsh +33 -33
  52. package/shell/completions/_che +19 -19
  53. package/shell/completions/che.bash +15 -15
  54. package/shell/completions/che.fish +34 -50
@@ -20,17 +20,19 @@ from __future__ import annotations
20
20
  import os
21
21
  import re
22
22
  import sys
23
+ from dataclasses import dataclass
23
24
  from pathlib import Path
24
25
 
25
26
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
26
27
 
28
+ from media import LIBRARY_EXTENSIONS
27
29
  from utils import (
28
30
  add_bool_flag,
29
31
  build_parser,
30
32
  info,
31
33
  iter_files,
32
34
  log,
33
- note,
35
+ note_settings,
34
36
  run_cli,
35
37
  scan_root,
36
38
  warning,
@@ -38,8 +40,6 @@ from utils import (
38
40
 
39
41
  __version__ = "3.0.0"
40
42
 
41
- VIDEO_EXTENSIONS = ("mp4", "mkv")
42
-
43
43
  # Every flavour of curly quote, backtick and acute accent people end up with,
44
44
  # normalised to a plain straight apostrophe.
45
45
  _APOSTROPHES = str.maketrans({"’": "'", "‘": "'", "´": "'", "`": "'"}) # noqa: RUF001
@@ -100,8 +100,8 @@ def title_case(text: str, pattern: re.Pattern[str]) -> str:
100
100
  def replace(match: re.Match[str]) -> str:
101
101
  if match.group("code"):
102
102
  return match.group("code").upper()
103
- if "keep" in match.groupdict() and match.group("keep"):
104
- return match.group("keep")
103
+ if keep := match.groupdict().get("keep"):
104
+ return keep
105
105
  return match.group("first").upper() + match.group("rest").lower()
106
106
 
107
107
  return pattern.sub(replace, text)
@@ -123,51 +123,59 @@ def normalize(text: str) -> str:
123
123
  return _CODE_REJOIN.sub(r"\1-\2", text)
124
124
 
125
125
 
126
- def clean_segment(text: str, pattern: re.Pattern[str], *, capitalize_preps: bool) -> str:
127
- """Title-case one name segment end to end."""
128
- result = title_case(text, pattern)
129
- if not capitalize_preps:
130
- result = apply_preposition_rules(result)
131
- return result
126
+ def _lower_contractions(text: str) -> str:
127
+ """Lowercase the one or two letters after an apostrophe: "It'S" -> "It's"."""
128
+ return _APOSTROPHE_SUFFIX.sub(lambda m: "'" + m.group(1).lower(), text)
132
129
 
133
130
 
134
- def clean_folder_name(name: str, pattern: re.Pattern[str], *, capitalize_preps: bool) -> str:
135
- """The corrected name for a folder.
131
+ @dataclass(frozen=True)
132
+ class TitleCaser:
133
+ """The casing policy for one run: the words to keep, and the prepositions rule."""
136
134
 
137
- Folders now get the same curly-quote normalisation as files. The shell
138
- version appeared to do this too, but both of its folder-branch sed patterns
139
- were the plain ASCII apostrophe - two no-ops - so a folder called
140
- ``l'auberge`` with a typographic apostrophe was left alone.
141
- """
142
- result = clean_segment(normalize(name), pattern, capitalize_preps=capitalize_preps)
143
- return _APOSTROPHE_SUFFIX.sub(lambda m: "'" + m.group(1).lower(), result)
135
+ pattern: re.Pattern[str]
136
+ capitalize_preps: bool
144
137
 
138
+ @classmethod
139
+ def build(cls, ignore_words: list[str], *, capitalize_preps: bool) -> TitleCaser:
140
+ return cls(build_title_pattern(ignore_words), capitalize_preps)
145
141
 
146
- def clean_file_name(name: str, pattern: re.Pattern[str], *, capitalize_preps: bool) -> str | None:
147
- """The corrected name for a file, or None when it cannot be parsed.
142
+ def segment(self, text: str) -> str:
143
+ """Title-case one name segment end to end."""
144
+ result = title_case(text, self.pattern)
145
+ return result if self.capitalize_preps else apply_preposition_rules(result)
148
146
 
149
- Anchored with ``fullmatch`` where the shell version used an unanchored
150
- ``[[ =~ ]]`` search. The two agree on every ordinary name; the difference
151
- is dot-leading ones, where the shell version matched from after the dot and
152
- so renamed ``.hidden movie.mkv`` to ``Hidden Movie.mkv`` - silently
153
- un-hiding the file. Those are reported as unparseable instead.
154
- """
155
- normalized = normalize(name)
147
+ def folder_name(self, name: str) -> str:
148
+ """The corrected name for a folder.
149
+
150
+ Folders now get the same curly-quote normalisation as files. The shell
151
+ version appeared to do this too, but both of its folder-branch sed
152
+ patterns were the plain ASCII apostrophe - two no-ops - so a folder
153
+ called ``l'auberge`` with a typographic apostrophe was left alone.
154
+ """
155
+ return _lower_contractions(self.segment(normalize(name)))
156
+
157
+ def file_name(self, name: str) -> str | None:
158
+ """The corrected name for a file, or None when it cannot be parsed.
156
159
 
157
- match = _DASHED_NAME.fullmatch(normalized)
158
- if match:
159
- prefix, title, ext = match.group(1), match.group(2), match.group(3)
160
- else:
161
- match = _PLAIN_NAME.fullmatch(normalized)
162
- if not match:
163
- return None
164
- prefix, title, ext = "", match.group(1), match.group(2)
160
+ Anchored with ``fullmatch`` where the shell version used an unanchored
161
+ ``[[ =~ ]]`` search. The two agree on every ordinary name; the
162
+ difference is dot-leading ones, where the shell version matched from
163
+ after the dot and so renamed ``.hidden movie.mkv`` to ``Hidden
164
+ Movie.mkv`` - silently un-hiding the file. Those are reported as
165
+ unparseable instead.
166
+ """
167
+ normalized = normalize(name)
165
168
 
166
- prefix = clean_segment(prefix, pattern, capitalize_preps=capitalize_preps)
167
- title = clean_segment(title, pattern, capitalize_preps=capitalize_preps)
169
+ match = _DASHED_NAME.fullmatch(normalized)
170
+ if match:
171
+ prefix, title, ext = match.group(1), match.group(2), match.group(3)
172
+ else:
173
+ match = _PLAIN_NAME.fullmatch(normalized)
174
+ if not match:
175
+ return None
176
+ prefix, title, ext = "", match.group(1), match.group(2)
168
177
 
169
- result = f"{prefix}{title}{ext}"
170
- return _APOSTROPHE_SUFFIX.sub(lambda m: "'" + m.group(1).lower(), result)
178
+ return _lower_contractions(f"{self.segment(prefix)}{self.segment(title)}{ext}")
171
179
 
172
180
 
173
181
  def safe_rename(source: Path, target: Path, *, kind: str) -> bool:
@@ -192,29 +200,28 @@ def safe_rename(source: Path, target: Path, *, kind: str) -> bool:
192
200
  return True
193
201
 
194
202
 
195
- def rename_folders(
196
- root: Path, pattern: re.Pattern[str], *, recursive: bool, capitalize_preps: bool, dry_run: bool
197
- ) -> None:
203
+ def rename_folders(root: Path, caser: TitleCaser, *, recursive: bool, dry_run: bool) -> None:
198
204
  """Rename folders deepest-first so a parent rename cannot break child paths."""
199
205
  directories: list[Path] = []
200
- for dirpath, _, _ in os.walk(root, topdown=False, followlinks=False):
206
+ for dirpath, dirnames, _ in os.walk(root, followlinks=False):
201
207
  directory = Path(dirpath)
202
208
  if directory == root:
203
209
  continue
204
- if not recursive and directory.parent != root:
205
- continue
206
210
  directories.append(directory)
211
+ # Not recursive: the root's own children are the only folders renamed,
212
+ # so the walk need not open anything below them.
213
+ if not recursive:
214
+ dirnames.clear()
207
215
 
208
- # Sorting the collected list, not os.walk's dirnames: under topdown=False
209
- # the recursion has already happened, so mutating dirnames does nothing.
210
- # Deepest first, so renaming a parent cannot invalidate a child's path.
216
+ # The walk is top-down, so the list is re-sorted: deepest first, so
217
+ # renaming a parent cannot invalidate a child's path.
211
218
  directories.sort(key=lambda p: (-len(p.parts), p))
212
219
 
213
220
  for folder in directories:
214
221
  if folder.name.startswith("._"):
215
222
  continue
216
223
 
217
- new_name = clean_folder_name(folder.name, pattern, capitalize_preps=capitalize_preps)
224
+ new_name = caser.folder_name(folder.name)
218
225
  if new_name == folder.name:
219
226
  warning(f"⚠️ Skipping folder (already correct format): {folder.name}")
220
227
  continue
@@ -228,16 +235,14 @@ def rename_folders(
228
235
  log(f"📁 Renaming folder: {folder.name} ➡️ {new_name}")
229
236
 
230
237
 
231
- def rename_files(
232
- root: Path, pattern: re.Pattern[str], *, recursive: bool, capitalize_preps: bool, dry_run: bool
233
- ) -> None:
238
+ def rename_files(root: Path, caser: TitleCaser, *, recursive: bool, dry_run: bool) -> None:
234
239
  """Rename the .mp4/.mkv files themselves."""
235
240
  # case_sensitive: this script's find used -name, not -iname, so a file
236
241
  # called "Movie.MKV" was never renamed.
237
242
  for path in iter_files(
238
- root, extensions=VIDEO_EXTENSIONS, recursive=recursive, case_sensitive=True
243
+ root, extensions=LIBRARY_EXTENSIONS, recursive=recursive, case_sensitive=True
239
244
  ):
240
- new_name = clean_file_name(path.name, pattern, capitalize_preps=capitalize_preps)
245
+ new_name = caser.file_name(path.name)
241
246
  if new_name is None:
242
247
  warning(f"Could not parse filename: {path.name}")
243
248
  continue
@@ -262,13 +267,10 @@ def main(argv: list[str] | None = None) -> int:
262
267
  )
263
268
  parser.add_argument("--path", default="", metavar="DIR", help="Directory to scan (required)")
264
269
  add_bool_flag(parser, "--recursive", default=True, help="Recurse into subdirectories")
265
- add_bool_flag(
266
- parser, "--rename-folders", dest="rename_folders", default=True, help="Rename folders too"
267
- )
270
+ add_bool_flag(parser, "--rename-folders", default=True, help="Rename folders too")
268
271
  add_bool_flag(
269
272
  parser,
270
273
  "--capitalize-preps",
271
- dest="capitalize_preps",
272
274
  help="Capitalize articles and prepositions",
273
275
  )
274
276
  # Deliberately NOT env_bool: unlike the delete tools, the shell version
@@ -284,7 +286,6 @@ def main(argv: list[str] | None = None) -> int:
284
286
  parser.add_argument(
285
287
  "--ignore-words",
286
288
  default="",
287
- dest="ignore_words",
288
289
  metavar="LIST",
289
290
  help="Comma-separated words to leave exactly as written",
290
291
  )
@@ -294,34 +295,23 @@ def main(argv: list[str] | None = None) -> int:
294
295
 
295
296
  ignore_words = [word.strip() for word in args.ignore_words.split(",") if word.strip()]
296
297
 
297
- note(f"Scanning: {args.path}")
298
- note(f"Recursive: {str(args.recursive).lower()}")
299
- note(f"Rename Folders: {str(args.rename_folders).lower()}")
300
- note(f"Capitalize Prepositions: {str(args.capitalize_preps).lower()}")
301
- note(f"Dry Run: {str(args.dry_run).lower()}")
298
+ settings: list[tuple[str, object]] = [
299
+ ("Scanning", args.path),
300
+ ("Recursive", args.recursive),
301
+ ("Rename Folders", args.rename_folders),
302
+ ("Capitalize Prepositions", args.capitalize_preps),
303
+ ("Dry Run", args.dry_run),
304
+ ]
302
305
  if ignore_words:
303
- note(f"Ignore Words: {','.join(ignore_words)}")
304
- note("----------------------------------------------------")
306
+ settings.append(("Ignore Words", ",".join(ignore_words)))
307
+ note_settings(*settings)
305
308
 
306
309
  root = scan_root(args.path)
307
- pattern = build_title_pattern(ignore_words)
310
+ caser = TitleCaser.build(ignore_words, capitalize_preps=args.capitalize_preps)
308
311
 
309
312
  if args.rename_folders:
310
- rename_folders(
311
- root,
312
- pattern,
313
- recursive=args.recursive,
314
- capitalize_preps=args.capitalize_preps,
315
- dry_run=args.dry_run,
316
- )
317
-
318
- rename_files(
319
- root,
320
- pattern,
321
- recursive=args.recursive,
322
- capitalize_preps=args.capitalize_preps,
323
- dry_run=args.dry_run,
324
- )
313
+ rename_folders(root, caser, recursive=args.recursive, dry_run=args.dry_run)
314
+ rename_files(root, caser, recursive=args.recursive, dry_run=args.dry_run)
325
315
  return 0
326
316
 
327
317
 
@@ -16,20 +16,19 @@ from pathlib import Path
16
16
 
17
17
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
18
18
 
19
+ from media import probe_media
19
20
  from utils import (
20
- NC,
21
21
  build_parser,
22
- color_enabled,
23
- ffprobe_entries,
24
22
  info,
25
23
  iter_files,
24
+ paint,
26
25
  require_binary,
27
26
  run_cli,
28
27
  scan_root,
29
28
  warning,
30
29
  )
31
30
 
32
- __version__ = "3.0.0"
31
+ __version__ = "3.1.0"
33
32
 
34
33
  VIDEO_EXTENSIONS = ("mp4", "mkv", "avi", "mov", "flv", "wmv", "webm")
35
34
 
@@ -49,22 +48,6 @@ LANG_COLOR = {
49
48
  DEFAULT_COLOR = "\033[90m" # Gray for unknown languages
50
49
 
51
50
 
52
- _UNREADABLE = "\x00unreadable"
53
-
54
-
55
- def audio_languages(path: Path) -> list[str] | None:
56
- """Sorted, de-duplicated language tags, or None when ffprobe cannot read it.
57
-
58
- Telling "no language tags" apart from "could not be read" matters: the
59
- shell version aborted the whole scan on an unreadable file (`set -e` on a
60
- failing command substitution), so it never had to render the difference.
61
- """
62
- raw = ffprobe_entries(path, "stream_tags=language", stream="a", on_error=_UNREADABLE)
63
- if raw == _UNREADABLE:
64
- return None
65
- return sorted({line.strip() for line in raw.splitlines() if line.strip()})
66
-
67
-
68
51
  def main(argv: list[str] | None = None) -> int:
69
52
  parser = build_parser(
70
53
  prog="scan-videos-audio-language",
@@ -74,28 +57,28 @@ def main(argv: list[str] | None = None) -> int:
74
57
  info(f"Running command in {Path.cwd()}")
75
58
  args = parser.parse_args(argv)
76
59
 
77
- if not args.path:
78
- info(f"❌ Error: '{args.path}' is not a valid directory")
79
- return 1
80
-
60
+ # An empty --path reads "--path is required", where the shell version
61
+ # reported `'' is not a valid directory`.
81
62
  root = scan_root(args.path)
82
63
 
83
64
  require_binary("ffprobe", hint="ffprobe (from ffmpeg) is required but not installed.")
84
65
 
85
- # The per-language escapes are baked into the string before info() sees
86
- # it, so NO_COLOR has to be honoured here as well or it would leak.
87
- colors = color_enabled()
88
-
89
- def paint(text: str, color: str) -> str:
90
- return f"{color}{text}{NC}" if colors else text
91
-
92
66
  for path in iter_files(root, extensions=VIDEO_EXTENSIONS):
93
67
  info(f"File: {path}")
94
68
 
95
- languages = audio_languages(path)
96
- if languages is None:
69
+ # Telling "no language tags" apart from "could not be read" matters:
70
+ # the shell version aborted the whole scan on an unreadable file
71
+ # (`set -e` on a failing command substitution), so it never had to
72
+ # render the difference.
73
+ probe = probe_media(path)
74
+ if probe is None:
97
75
  warning(f"Audio language(s): {paint('Unreadable - ffprobe failed', DEFAULT_COLOR)}")
98
76
  continue
77
+
78
+ # DELIBERATE DIVERGENCE: the shell version's csv output read `und,`
79
+ # for AC3 or E-AC3 audio in an MP4 on ffprobe 9 (see MediaProbe), and
80
+ # a bare `,` when such a stream had no tag. Both now read cleanly.
81
+ languages = probe.languages("audio")
99
82
  if not languages:
100
83
  info(f"Audio language(s): {paint('Unknown or not tagged', DEFAULT_COLOR)}")
101
84
  continue
@@ -17,21 +17,21 @@ from pathlib import Path
17
17
 
18
18
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
19
19
 
20
+ from media import CODEC_SCAN_EXTENSIONS, probe_media
20
21
  from utils import (
21
22
  add_bool_flag,
22
23
  build_parser,
23
- ffprobe_entries,
24
24
  info,
25
25
  iter_files,
26
26
  log,
27
- note,
27
+ note_settings,
28
28
  require_binary,
29
29
  run_cli,
30
30
  scan_root,
31
31
  warning,
32
32
  )
33
33
 
34
- __version__ = "3.0.0"
34
+ __version__ = "3.1.0"
35
35
 
36
36
  # The common safe set for Direct Play. Tuples, not sets, so the banner prints
37
37
  # them in the order the shell version's arrays declared them.
@@ -39,8 +39,6 @@ ALLOWED_VIDEO_CODECS = ("h264", "hevc")
39
39
  ALLOWED_AUDIO_CODECS = ("aac", "ac3", "eac3")
40
40
  ALLOWED_CONTAINERS = ("mov", "mp4", "mkv")
41
41
 
42
- SCAN_EXTENSIONS = ("mp4", "mkv", "mov", "avi")
43
-
44
42
 
45
43
  def main(argv: list[str] | None = None) -> int:
46
44
  parser = build_parser(
@@ -52,18 +50,12 @@ def main(argv: list[str] | None = None) -> int:
52
50
  info(f"Running command in {Path.cwd()}")
53
51
  args = parser.parse_args(argv)
54
52
 
55
- note(f"Scanning: {args.path}")
56
- note(f"Verbose: {str(args.verbose).lower()}")
57
- note("----------------------------------------------------")
53
+ note_settings(("Scanning", args.path), ("Verbose", args.verbose))
58
54
 
59
- if not args.path:
60
- warning("❌ --path is required")
61
- return 1
55
+ root = scan_root(args.path)
62
56
 
63
57
  require_binary("ffprobe", hint="Install FFmpeg: brew install ffmpeg")
64
58
 
65
- root = scan_root(args.path)
66
-
67
59
  print(f"Scanning for problematic codecs in: {args.path}")
68
60
  print(f"Allowed Video: {' '.join(ALLOWED_VIDEO_CODECS)}")
69
61
  print(f"Allowed Audio: {' '.join(ALLOWED_AUDIO_CODECS)}")
@@ -72,19 +64,23 @@ def main(argv: list[str] | None = None) -> int:
72
64
 
73
65
  problems = 0
74
66
 
75
- for path in iter_files(root, extensions=SCAN_EXTENSIONS):
67
+ for path in iter_files(root, extensions=CODEC_SCAN_EXTENSIONS):
76
68
  container = path.name.lower().rsplit(".", 1)[-1]
77
69
 
78
- # "unknown" only when ffprobe itself FAILS (a corrupt file), matching
79
- # the shell version's `|| echo "unknown"`. A file that probes fine but
80
- # has no audio track keeps the empty string the shell version stored,
81
- # so the two cases stay distinguishable in the report.
82
- video_codec = ffprobe_entries(
83
- path, "stream=codec_name", stream="v:0", on_error="unknown"
84
- ).lower()
85
- audio_codec = ffprobe_entries(
86
- path, "stream=codec_name", stream="a:0", on_error="unknown"
87
- ).lower()
70
+ # "unknown" when ffprobe itself FAILS (a corrupt file), matching the
71
+ # shell version's `|| echo "unknown"`, or when it cannot name a codec.
72
+ # A file that probes fine but has no audio track keeps the empty
73
+ # string the shell version stored, so the cases stay distinguishable.
74
+ #
75
+ # DELIBERATE DIVERGENCE: the shell version's csv output read `ac3,`
76
+ # for AC3 or E-AC3 audio in an MP4 on ffprobe 9 (see MediaProbe), so
77
+ # every such file failed the allowed-codec check. They now pass.
78
+ probe = probe_media(path)
79
+ if probe is None:
80
+ video_codec = audio_codec = "unknown"
81
+ else:
82
+ video_codec = probe.codec("video").lower()
83
+ audio_codec = probe.codec("audio").lower()
88
84
 
89
85
  ok = (
90
86
  video_codec in ALLOWED_VIDEO_CODECS
@@ -18,6 +18,7 @@ from pathlib import Path
18
18
 
19
19
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
20
20
 
21
+ from media import LIBRARY_EXTENSIONS
21
22
  from utils import (
22
23
  add_bool_flag,
23
24
  build_parser,
@@ -31,9 +32,7 @@ from utils import (
31
32
  warning,
32
33
  )
33
34
 
34
- __version__ = "3.0.0"
35
-
36
- VIDEO_EXTENSIONS = ("mp4", "mkv")
35
+ __version__ = "3.1.0"
37
36
 
38
37
 
39
38
  def main(argv: list[str] | None = None) -> int:
@@ -46,10 +45,8 @@ def main(argv: list[str] | None = None) -> int:
46
45
  info(f"Running command in {Path.cwd()}")
47
46
  args = parser.parse_args(argv)
48
47
 
49
- if not args.path:
50
- warning(f"❌ Error: '{args.path}' is not a valid directory")
51
- return 1
52
-
48
+ # An empty --path reads "--path is required", where the shell version
49
+ # reported `'' is not a valid directory`.
53
50
  root = scan_root(args.path)
54
51
 
55
52
  require_binary("mpv", hint="Install it using Homebrew:\n brew install mpv")
@@ -57,7 +54,7 @@ def main(argv: list[str] | None = None) -> int:
57
54
  checked = 0
58
55
  bad: list[Path] = []
59
56
 
60
- for path in iter_files(root, extensions=VIDEO_EXTENSIONS):
57
+ for path in iter_files(root, extensions=LIBRARY_EXTENSIONS):
61
58
  # The shell version gated this on `[[ -z "$VERBOSE" ]]`, but VERBOSE is
62
59
  # the literal string "false" when unset, which is never empty - so the
63
60
  # line never printed and --verbose did nothing. Wired up properly here.
@@ -21,10 +21,10 @@ from pathlib import Path
21
21
 
22
22
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
23
23
 
24
+ from media import LIBRARY_EXTENSIONS
24
25
  from utils import (
25
26
  CYAN,
26
27
  GREEN,
27
- NC,
28
28
  YELLOW,
29
29
  add_bool_flag,
30
30
  build_parser,
@@ -32,15 +32,14 @@ from utils import (
32
32
  info,
33
33
  iter_files,
34
34
  log,
35
- note,
35
+ note_settings,
36
+ paint,
36
37
  run_cli,
37
38
  scan_root,
38
- warning,
39
39
  )
40
40
 
41
- __version__ = "3.0.0"
41
+ __version__ = "3.1.0"
42
42
 
43
- VIDEO_EXTENSIONS = ("mp4", "mkv")
44
43
  SORT_METHODS = ("alpha", "fileSizeAsc", "fileSizeDesc")
45
44
 
46
45
 
@@ -51,7 +50,7 @@ def main(argv: list[str] | None = None) -> int:
51
50
  )
52
51
  parser.add_argument("--path", default="", metavar="DIR", help="Directory to scan (required)")
53
52
  add_bool_flag(parser, "--recursive", help="Recurse into subdirectories")
54
- add_bool_flag(parser, "--with-folder", dest="with_folder", help="Prefix with folder name")
53
+ add_bool_flag(parser, "--with-folder", help="Prefix with folder name")
55
54
  parser.add_argument(
56
55
  "--sort",
57
56
  default="alpha",
@@ -62,15 +61,12 @@ def main(argv: list[str] | None = None) -> int:
62
61
  info(f"Running command in {Path.cwd()}")
63
62
  args = parser.parse_args(argv)
64
63
 
65
- note(f"Scanning: {args.path}")
66
- note(f"Recursive: {str(args.recursive).lower()}")
67
- note(f"Sort method: {args.sort}")
68
- note(f"With folder: {str(args.with_folder).lower()}")
69
- note("----------------------------------------------------")
70
-
71
- if not args.path:
72
- warning("❌ --path is required")
73
- return 1
64
+ note_settings(
65
+ ("Scanning", args.path),
66
+ ("Recursive", args.recursive),
67
+ ("Sort method", args.sort),
68
+ ("With folder", args.with_folder),
69
+ )
74
70
 
75
71
  # Replaces the shell version's `find` error on a missing path with a
76
72
  # styled message; the exit code is unchanged.
@@ -79,7 +75,7 @@ def main(argv: list[str] | None = None) -> int:
79
75
  entries: list[tuple[int, Path]] = []
80
76
  for path in iter_files(
81
77
  root,
82
- extensions=VIDEO_EXTENSIONS,
78
+ extensions=LIBRARY_EXTENSIONS,
83
79
  recursive=args.recursive,
84
80
  skip_hidden=True,
85
81
  ):
@@ -101,16 +97,17 @@ def main(argv: list[str] | None = None) -> int:
101
97
  locale.setlocale(locale.LC_COLLATE, "")
102
98
  entries.sort(key=lambda item: locale.strxfrm(str(item[1])))
103
99
 
100
+ # DELIBERATE DIVERGENCE: the shell version baked these escapes into every
101
+ # line unconditionally, so NO_COLOR or a pipe still got them raw. paint()
102
+ # honours both, like every other coloured line in the repo.
104
103
  for size, path in entries:
105
- size_human = format_bytes(size)
104
+ display = f"{paint(path.name, GREEN)} {paint(f'[{format_bytes(size)}]', YELLOW)}"
106
105
  if args.with_folder:
107
106
  # `basename $(dirname ./x.mkv)` is ".", but pathlib collapses the
108
107
  # "." away and leaves parent.name empty - which would render as a
109
108
  # bare leading slash and read like an absolute path.
110
109
  folder = path.parent.name or path.parent.as_posix()
111
- display = f"{CYAN}{folder}{NC}/{GREEN}{path.name}{NC} {YELLOW}[{size_human}]{NC}"
112
- else:
113
- display = f"{GREEN}{path.name}{NC} {YELLOW}[{size_human}]{NC}"
110
+ display = f"{paint(folder, CYAN)}/{display}"
114
111
  log(display)
115
112
 
116
113
  return 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codebend3r/che",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Helper scripts shared across machines, called by name from your shell and browsable through one menu",
5
5
  "keywords": [
6
6
  "cli",
@@ -27,7 +27,6 @@
27
27
  "files": [
28
28
  "bin/",
29
29
  "shell/",
30
- "!bin/version-bump.py",
31
30
  "!**/__pycache__/",
32
31
  "!**/*.pyc"
33
32
  ],
@@ -36,19 +35,14 @@
36
35
  },
37
36
  "scripts": {
38
37
  "test": "uv run --no-project --with-requirements requirements-dev.txt -- pytest",
39
- "ci": "run-s lint format syntax executable shebang generated test",
38
+ "ci": "bun run --sequential lint format executable shebang test",
40
39
  "lint": "uv run --no-project --with-requirements requirements-dev.txt -- ruff check bin test",
41
40
  "format": "uv run --no-project --with-requirements requirements-dev.txt -- ruff format --check bin test",
42
- "syntax": "find bin test -type f -name '*.py' -print0 | xargs -0 -n1 python3 -m py_compile",
43
41
  "executable": "find bin -type f -name '*.py' ! -perm -u+x -print0 | xargs -0 awk 'FNR==1 && /^#!/{print \"Has a shebang but is not executable: \" FILENAME; ec=1} END{exit ec+0}'",
44
42
  "shebang": "find bin -type f -name '*.py' -perm -u+x -print0 | xargs -0 awk 'FNR==1 && !/^#!/{print \"Executable but has no shebang: \" FILENAME; ec=1} END{exit ec+0}'",
45
- "generate": "python3 bin/install.py --generate",
46
- "generated": "python3 bin/install.py --check",
43
+ "generate": "python3 bin/shellgen.py",
47
44
  "version": "bun run generate && git add shell .zshrc README.md",
48
45
  "prepublishOnly": "bun run ci"
49
46
  },
50
- "packageManager": "bun@1.3.14",
51
- "devDependencies": {
52
- "npm-run-all2": "9.0.1"
53
- }
47
+ "packageManager": "bun@1.4.2"
54
48
  }