@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
package/bin/machine.py ADDED
@@ -0,0 +1,345 @@
1
+ """Facts about the machine che is installed on.
2
+
3
+ Where che keeps its config, which shells exist and which rc files each one
4
+ reads, which python can run the scripts, which external tools are missing, and
5
+ whether this copy of che is a git clone. The installer, the doctor and the
6
+ dispatcher all ask these questions; the install record is the only thing
7
+ written from here.
8
+
9
+ Every path is resolved from the environment at call time, never at import
10
+ time, so the tests can point HOME at a temporary directory.
11
+
12
+ Library module: no shebang, not executable.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import shutil
20
+ import subprocess
21
+ import sys
22
+ from collections.abc import Callable
23
+ from dataclasses import asdict, dataclass, field, fields, replace
24
+ from pathlib import Path
25
+
26
+ from commands import PACKAGE_NAME, REPO, required_binaries
27
+ from utils import atomic_write_text, run
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # External tools
31
+ # ---------------------------------------------------------------------------
32
+
33
+ # Binary -> the Homebrew formula that provides it, which is what
34
+ # `che install --install-deps` installs.
35
+ BREW_FORMULA: dict[str, str] = {
36
+ "ffmpeg": "ffmpeg",
37
+ "ffprobe": "ffmpeg",
38
+ "mpv": "mpv",
39
+ "exiftool": "exiftool",
40
+ "gh": "gh",
41
+ "btop": "btop",
42
+ "git": "git",
43
+ }
44
+
45
+ # Binary -> how to get it. Anything absent from here is either part of macOS or
46
+ # has no obvious one-liner, and doctor says so rather than inventing a command.
47
+ INSTALL_HINTS: dict[str, str] = {
48
+ **{binary: f"brew install {formula}" for binary, formula in BREW_FORMULA.items()},
49
+ "brew": 'bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
50
+ }
51
+
52
+ # Binaries that ship with macOS: never suggest installing them, just report.
53
+ SYSTEM_BINARIES = frozenset({"diskutil", "osascript"})
54
+
55
+
56
+ def missing_binaries() -> list[str]:
57
+ """Tools some command needs that are not on PATH, in name order, macOS's own aside."""
58
+ return [
59
+ binary
60
+ for binary in required_binaries()
61
+ if binary not in SYSTEM_BINARIES and not shutil.which(binary)
62
+ ]
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Locations
67
+ # ---------------------------------------------------------------------------
68
+
69
+
70
+ def home() -> Path:
71
+ return Path(os.environ.get("HOME", str(Path.home())))
72
+
73
+
74
+ def xdg_config_home() -> Path:
75
+ return Path(os.environ.get("XDG_CONFIG_HOME") or home() / ".config")
76
+
77
+
78
+ def config_dir() -> Path:
79
+ return xdg_config_home() / "che"
80
+
81
+
82
+ def config_file() -> Path:
83
+ return config_dir() / "config.json"
84
+
85
+
86
+ def backup_dir() -> Path:
87
+ return config_dir() / "backups"
88
+
89
+
90
+ def state_dir() -> Path:
91
+ return Path(os.environ.get("XDG_STATE_HOME") or home() / ".local" / "state") / "che"
92
+
93
+
94
+ def shim_path() -> Path:
95
+ return home() / ".local" / "bin" / "che"
96
+
97
+
98
+ def shim_on_path() -> bool:
99
+ entries = os.environ.get("PATH", "").split(os.pathsep)
100
+ return str(shim_path().parent) in entries
101
+
102
+
103
+ def tildify(path: Path) -> str:
104
+ """``path`` for display, with the home directory spelled ``~``."""
105
+ try:
106
+ return f"~/{path.relative_to(home())}"
107
+ except ValueError:
108
+ return str(path)
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # Shells
113
+ # ---------------------------------------------------------------------------
114
+
115
+
116
+ def _bash_rc_files() -> list[Path]:
117
+ """Which bash startup files to write.
118
+
119
+ macOS Terminal and iTerm start bash as a *login* shell, which reads
120
+ ``.bash_profile`` and never ``.bashrc``; most Linux terminals do the
121
+ opposite. Writing to whichever already exist (and to ``.bashrc`` when
122
+ neither does) covers both without the usual "why isn't my alias there"
123
+ hunt. The block is idempotent, so a ``.bash_profile`` that sources
124
+ ``.bashrc`` just defines the same functions twice, harmlessly.
125
+ """
126
+ candidates = [home() / ".bashrc", home() / ".bash_profile"]
127
+ existing = [path for path in candidates if path.exists()]
128
+ return existing or [home() / ".bashrc"]
129
+
130
+
131
+ def _zsh_rc_files() -> list[Path]:
132
+ zdotdir = os.environ.get("ZDOTDIR")
133
+ base = Path(zdotdir) if zdotdir else home()
134
+ return [base / ".zshrc"]
135
+
136
+
137
+ def _fish_rc_files() -> list[Path]:
138
+ return [xdg_config_home() / "fish" / "config.fish"]
139
+
140
+
141
+ def _sh_rc_files() -> list[Path]:
142
+ return [home() / ".profile"]
143
+
144
+
145
+ def login_shell() -> str:
146
+ return Path(os.environ.get("SHELL", "")).name
147
+
148
+
149
+ @dataclass(frozen=True)
150
+ class ShellSpec:
151
+ """A shell che can install into, how, and how it stands on this machine.
152
+
153
+ The properties are read from the environment each time they are asked, so
154
+ a test that moves HOME or ZDOTDIR sees the move.
155
+ """
156
+
157
+ name: str
158
+ find_rc_files: Callable[[], list[Path]]
159
+ # The generated file the block sources, `shell/che.<flavor>`, and so the
160
+ # syntax the block itself is written in.
161
+ flavor: str
162
+ # Never installed into unless asked for, and a missing block only warns.
163
+ opt_in: bool = False
164
+
165
+ @property
166
+ def rc_files(self) -> list[Path]:
167
+ """The rc files an install into this shell writes."""
168
+ return self.find_rc_files()
169
+
170
+ @property
171
+ def installed(self) -> bool:
172
+ """Whether the shell itself exists on this machine."""
173
+ return shutil.which(self.name) is not None
174
+
175
+ @property
176
+ def has_rc(self) -> bool:
177
+ return any(path.exists() for path in self.rc_files)
178
+
179
+ @property
180
+ def is_login_shell(self) -> bool:
181
+ return self.name == login_shell()
182
+
183
+
184
+ SHELLS: dict[str, ShellSpec] = {
185
+ spec.name: spec
186
+ for spec in (
187
+ ShellSpec("zsh", _zsh_rc_files, "zsh"),
188
+ ShellSpec("bash", _bash_rc_files, "bash"),
189
+ ShellSpec("fish", _fish_rc_files, "fish"),
190
+ # `sh` reuses the bash wrapper file: it is plain POSIX shell apart from
191
+ # the completion tail, which is guarded by a $BASH_VERSION test. Opt-in,
192
+ # because `.profile` is read by every POSIX shell on the machine, which
193
+ # is rarely what someone means.
194
+ ShellSpec("sh", _sh_rc_files, "bash", opt_in=True),
195
+ )
196
+ }
197
+
198
+
199
+ def default_shell_selection() -> list[str]:
200
+ """What to install into when the user does not say.
201
+
202
+ The login shell always, plus any other shell that is both installed and
203
+ already has an rc file - those are shells the user demonstrably uses.
204
+ Opt-in shells are never chosen here.
205
+ """
206
+ specs = [spec for spec in SHELLS.values() if not spec.opt_in]
207
+ chosen = [spec.name for spec in specs if spec.is_login_shell]
208
+ chosen += [
209
+ spec.name for spec in specs if spec.name not in chosen and spec.installed and spec.has_rc
210
+ ]
211
+ return chosen or ["zsh"]
212
+
213
+
214
+ # ---------------------------------------------------------------------------
215
+ # The install record
216
+ # ---------------------------------------------------------------------------
217
+
218
+
219
+ @dataclass(frozen=True)
220
+ class InstallRecord:
221
+ """What ``config.json`` keeps about an install.
222
+
223
+ ``che update`` repeats the install from it, ``che doctor`` checks against
224
+ it, and its absence is how the dispatcher knows to run the first-run
225
+ wizard. Every field has a default so a record an older che wrote, missing
226
+ a key, still loads.
227
+ """
228
+
229
+ version: str = ""
230
+ home: str = ""
231
+ python: str = ""
232
+ shells: list[str] = field(default_factory=list)
233
+ rc_files: list[str] = field(default_factory=list)
234
+ sounds: bool = True
235
+ path_shim: bool = True
236
+ installed_at: str = ""
237
+
238
+
239
+ def load_record() -> InstallRecord | None:
240
+ """The recorded install, or None when there is none.
241
+
242
+ A missing, unreadable or empty file, or one that does not hold a JSON
243
+ object, all read as "not installed". Keys this che does not know (older
244
+ records carried ``completions``) are ignored, and an empty ``shells``
245
+ means the default selection - so ``che update``, which reinstalls into
246
+ them, and ``che doctor``, which checks them, agree on what it means.
247
+ """
248
+ try:
249
+ data = json.loads(config_file().read_text(encoding="utf-8"))
250
+ except (OSError, ValueError):
251
+ return None
252
+ if not isinstance(data, dict) or not data:
253
+ return None
254
+
255
+ known = {entry.name for entry in fields(InstallRecord)}
256
+ record = InstallRecord(**{key: value for key, value in data.items() if key in known})
257
+ return record if record.shells else replace(record, shells=default_shell_selection())
258
+
259
+
260
+ def save_record(record: InstallRecord, *, dry_run: bool = False) -> None:
261
+ if dry_run:
262
+ return
263
+ atomic_write_text(config_file(), json.dumps(asdict(record), indent=2) + "\n")
264
+
265
+
266
+ # ---------------------------------------------------------------------------
267
+ # Python interpreter
268
+ # ---------------------------------------------------------------------------
269
+
270
+ MIN_PYTHON = (3, 12)
271
+ PYTHON_HINT = "brew install python@3.13"
272
+
273
+
274
+ def version_text(version: tuple[int, ...]) -> str:
275
+ return ".".join(str(part) for part in version)
276
+
277
+
278
+ MIN_PYTHON_TEXT = version_text(MIN_PYTHON)
279
+
280
+
281
+ def python_ok(version: tuple[int, ...]) -> bool:
282
+ return tuple(version[:2]) >= MIN_PYTHON
283
+
284
+
285
+ def python_version(executable: str) -> tuple[int, ...] | None:
286
+ """Version of another interpreter, or None if it cannot be asked."""
287
+ if executable == sys.executable:
288
+ return sys.version_info[:3]
289
+ try:
290
+ output = run(
291
+ [executable, "-c", "import sys; print('.'.join(str(p) for p in sys.version_info[:3]))"],
292
+ capture=True,
293
+ check=True,
294
+ timeout=10,
295
+ ).stdout
296
+ except (OSError, subprocess.SubprocessError):
297
+ return None
298
+ try:
299
+ return tuple(int(part) for part in output.strip().split("."))
300
+ except ValueError:
301
+ return None
302
+
303
+
304
+ def find_python() -> tuple[str, tuple[int, ...]]:
305
+ """Pick the interpreter the wrappers should use.
306
+
307
+ The interpreter running this script wins when it is new enough - it is the
308
+ one the user just proved works. Otherwise fall back to the newest suitable
309
+ ``python3.X`` on PATH. macOS matters here: ``/usr/bin/python3`` is whatever
310
+ the Command Line Tools shipped, often years behind the Homebrew python that
311
+ is also installed. With nothing suitable anywhere, the running interpreter
312
+ comes back anyway, and the caller reports it as too old.
313
+ """
314
+ if python_ok(sys.version_info):
315
+ return sys.executable, sys.version_info[:3]
316
+
317
+ for candidate in ("python3.14", "python3.13", "python3.12", "python3"):
318
+ resolved = shutil.which(candidate)
319
+ if not resolved:
320
+ continue
321
+ version = python_version(resolved)
322
+ if version and python_ok(version):
323
+ return resolved, version
324
+
325
+ return sys.executable, sys.version_info[:3]
326
+
327
+
328
+ # ---------------------------------------------------------------------------
329
+ # Where this copy of che came from
330
+ # ---------------------------------------------------------------------------
331
+
332
+
333
+ def is_git_checkout() -> bool:
334
+ """Whether this copy of che is a clone, which ``che update`` can pull.
335
+
336
+ The npm package is the same tree without ``.git``.
337
+ """
338
+ return (REPO / ".git").exists()
339
+
340
+
341
+ def update_command() -> str:
342
+ """The command that fetches a newer che for this kind of install."""
343
+ if is_git_checkout():
344
+ return "git pull"
345
+ return f"npm install -g {PACKAGE_NAME}@latest"
package/bin/media.py ADDED
@@ -0,0 +1,127 @@
1
+ """ffprobe helpers and shared extension lists for the scripts under ``bin/video/``.
2
+
3
+ Split out of ``utils.py``, which keeps the logging, argument parsing and
4
+ subprocess plumbing every script shares. Scripts pick it up the same way::
5
+
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
10
+ from media import probe_media
11
+
12
+ Standard library only, like everything else at the ``bin/`` root. ffprobe
13
+ itself is an external binary; callers check for it with ``require_binary``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ from utils import run
23
+
24
+ # The two containers the library is kept in: what delete-duplicate-videos,
25
+ # rename-video-file, validate-video-files and video-list all scan.
26
+ LIBRARY_EXTENSIONS = ("mp4", "mkv")
27
+
28
+ # What show-codecs audits and fix-codecs re-encodes. One list, so fix-codecs
29
+ # can never pass over a file show-codecs reported.
30
+ CODEC_SCAN_EXTENSIONS = ("mp4", "mkv", "mov", "avi")
31
+
32
+ # Everything any video script reads, so each file is opened once.
33
+ _PROBE_ENTRIES = "stream=codec_type,codec_name,width:stream_tags=language:format=bit_rate"
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class MediaProbe:
38
+ """ffprobe's view of one file's streams and container.
39
+
40
+ The codec and language accessors return what the one-field-per-call
41
+ ffprobe invocations the scripts used to make printed, so callers read the
42
+ same values as before. That takes restoring by hand: the JSON writer OMITS
43
+ a field ffprobe cannot fill, where the csv and default writers printed
44
+ ``unknown`` or ``N/A``. ``width`` and ``bit_rate`` are numbers instead,
45
+ ``None`` where ffprobe had nothing to report.
46
+
47
+ One difference is deliberate. On ffprobe 9 the csv writer appended a comma
48
+ per side-data block on the stream, so AC3 or E-AC3 audio in an MP4 came
49
+ back as ``ac3,`` and its language as ``und,``. JSON has no such artefact.
50
+ """
51
+
52
+ streams: tuple[dict, ...]
53
+ format: dict
54
+
55
+ @classmethod
56
+ def from_json(cls, data: dict) -> MediaProbe:
57
+ """Build from ffprobe's parsed ``-of json`` output."""
58
+ return cls(streams=tuple(data.get("streams", ())), format=data.get("format", {}))
59
+
60
+ def first(self, kind: str) -> dict | None:
61
+ """The first ``"video"``/``"audio"``/``"subtitle"`` stream: ``-select_streams v:0``."""
62
+ return next((s for s in self.streams if s.get("codec_type") == kind), None)
63
+
64
+ def codec(self, kind: str) -> str:
65
+ """The first ``kind`` stream's codec.
66
+
67
+ ``""`` when there is no such stream, ``"unknown"`` when there is one
68
+ but ffprobe cannot name its codec - the two values the per-field calls
69
+ printed for those cases.
70
+ """
71
+ stream = self.first(kind)
72
+ if stream is None:
73
+ return ""
74
+ return stream.get("codec_name", "unknown")
75
+
76
+ def width(self) -> int | None:
77
+ """The first video stream's width in pixels, or None."""
78
+ return _count((self.first("video") or {}).get("width", ""))
79
+
80
+ def bit_rate(self) -> int | None:
81
+ """The container's overall bit rate, or None where ffprobe said ``N/A``."""
82
+ return _count(self.format.get("bit_rate", ""))
83
+
84
+ def languages(self, kind: str) -> list[str]:
85
+ """Every language tagged on a ``kind`` stream, sorted and de-duplicated.
86
+
87
+ The key is matched case-insensitively because ``-show_entries
88
+ stream_tags=language`` matches that way, so a container's ``LANGUAGE``
89
+ tag arrives under its own spelling.
90
+ """
91
+ return sorted(
92
+ {
93
+ value.strip()
94
+ for stream in self.streams
95
+ if stream.get("codec_type") == kind
96
+ for key, value in stream.get("tags", {}).items()
97
+ if key.lower() == "language" and value.strip()
98
+ }
99
+ )
100
+
101
+
102
+ def _count(value: object) -> int | None:
103
+ """``value`` as a non-negative integer, or None when it is absent or not numeric."""
104
+ text = str(value)
105
+ return int(text) if text.isdecimal() else None
106
+
107
+
108
+ def probe_media(path: Path) -> MediaProbe | None:
109
+ """Probe ``path`` once for everything the video scripts read.
110
+
111
+ Returns None when ffprobe fails - a corrupt or unreadable file - which
112
+ callers have to tell apart from a readable file that lacks a stream or a
113
+ tag. One process per file, where the scripts used to spawn one per field:
114
+ on an SMB share each of those re-opened the file and re-read its header.
115
+ """
116
+ completed = run(
117
+ ["ffprobe", "-v", "error", "-show_entries", _PROBE_ENTRIES, "-of", "json", str(path)],
118
+ check=False,
119
+ capture=True,
120
+ )
121
+ if completed.returncode != 0:
122
+ return None
123
+ try:
124
+ data = json.loads(completed.stdout or "")
125
+ except json.JSONDecodeError:
126
+ return None
127
+ return MediaProbe.from_json(data)