@codebend3r/che 1.0.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 (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +424 -0
  3. package/bin/che.py +869 -0
  4. package/bin/commands.py +872 -0
  5. package/bin/drives/eject-all-drives.py +203 -0
  6. package/bin/drives/mount-all-drives.py +140 -0
  7. package/bin/drives/ping-nas.py +242 -0
  8. package/bin/files/compress-folders.py +188 -0
  9. package/bin/files/delete-by-ext.py +108 -0
  10. package/bin/files/delete-empty-folders.py +136 -0
  11. package/bin/files/delete-smb-files.py +105 -0
  12. package/bin/files/files-under-size.py +132 -0
  13. package/bin/files/find-largest-files.py +102 -0
  14. package/bin/files/make-alpha-dir.py +63 -0
  15. package/bin/git/all-actions.py +545 -0
  16. package/bin/git/checkout-my-branches.py +147 -0
  17. package/bin/git/clean-stale-branches.py +161 -0
  18. package/bin/git/prune-worktrees.py +143 -0
  19. package/bin/git/sync-all-branches.py +368 -0
  20. package/bin/git/update-local-branches.py +195 -0
  21. package/bin/install.py +1515 -0
  22. package/bin/shellgen.py +782 -0
  23. package/bin/system/btop-launch.py +114 -0
  24. package/bin/system/update-brew.py +146 -0
  25. package/bin/tui.py +648 -0
  26. package/bin/utils.py +836 -0
  27. package/bin/video/delete-duplicate-videos.py +313 -0
  28. package/bin/video/detect-green-magenta-videos.py +268 -0
  29. package/bin/video/find-movie-by-year.py +84 -0
  30. package/bin/video/find-video-mkv-issues.py +242 -0
  31. package/bin/video/fix-codecs.py +209 -0
  32. package/bin/video/largest-tv-shows.py +175 -0
  33. package/bin/video/remove-metadata.py +126 -0
  34. package/bin/video/rename-video-file.py +329 -0
  35. package/bin/video/scan-videos-audio-language.py +110 -0
  36. package/bin/video/show-codecs.py +111 -0
  37. package/bin/video/validate-video-files.py +114 -0
  38. package/bin/video/video-list.py +120 -0
  39. package/package.json +54 -0
  40. package/shell/che.bash +382 -0
  41. package/shell/che.fish +282 -0
  42. package/shell/che.zsh +389 -0
  43. package/shell/completions/_che +146 -0
  44. package/shell/completions/che.bash +107 -0
  45. package/shell/completions/che.fish +371 -0
@@ -0,0 +1,188 @@
1
+ #!/usr/bin/env python3
2
+ """📦 Create a max-compression .zip of every immediate subfolder of a path.
3
+
4
+ Each ``<folder>`` becomes ``<folder>.zip`` alongside it.
5
+
6
+ Usage:
7
+ compress-folders.py --path=/dir [--dry-run] [--verbose]
8
+
9
+ Options:
10
+ --path=DIR Parent directory whose subfolders should be zipped (required)
11
+ --dry-run Print what would be zipped, change nothing
12
+ --verbose Show per-file progress
13
+ --help Show help
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import sys
20
+ import zipfile
21
+ from pathlib import Path
22
+
23
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
24
+
25
+ from utils import (
26
+ add_bool_flag,
27
+ build_parser,
28
+ info,
29
+ log,
30
+ note,
31
+ run_cli,
32
+ scan_root,
33
+ success,
34
+ warning,
35
+ )
36
+
37
+ __version__ = "3.0.0"
38
+
39
+
40
+ def _walk_entries(folder: Path) -> list[tuple[Path, str]]:
41
+ """Every entry to store, as ``(source_path, archive_name)`` pairs.
42
+
43
+ Symlinks are FOLLOWED, matching ``zip -r`` - which, contrary to a common
44
+ assumption, dereferences by default and only stores links as links with
45
+ ``-y``. Skipping them would silently drop file content from the archive.
46
+
47
+ Directory inodes already visited are not re-entered, so a symlink loop
48
+ cannot hang the walk. ``zip`` has its own loop protection; this is ours.
49
+ """
50
+ parent = folder.parent
51
+ entries: list[tuple[Path, str]] = [(folder, str(folder.relative_to(parent)))]
52
+ seen: set[tuple[int, int]] = set()
53
+
54
+ for dirpath, dirnames, filenames in os.walk(folder, followlinks=True):
55
+ directory = Path(dirpath)
56
+
57
+ try:
58
+ stat = directory.stat()
59
+ except OSError:
60
+ continue
61
+ key = (stat.st_dev, stat.st_ino)
62
+ if key in seen:
63
+ dirnames[:] = []
64
+ continue
65
+ seen.add(key)
66
+
67
+ dirnames.sort()
68
+ filenames.sort()
69
+
70
+ for name in dirnames:
71
+ child = directory / name
72
+ entries.append((child, str(child.relative_to(parent))))
73
+ for name in filenames:
74
+ child = directory / name
75
+ entries.append((child, str(child.relative_to(parent))))
76
+
77
+ return entries
78
+
79
+
80
+ def zip_folder(folder: Path, archive: Path, *, verbose: bool) -> None:
81
+ """Write ``folder`` into ``archive`` with deflate level 9.
82
+
83
+ Paths are stored relative to the folder's *parent*, so the archive expands
84
+ to ``<folder>/...``, and the top-level directory entry is written first -
85
+ without it an empty folder would archive to nothing at all, and its mode
86
+ bits would be lost on restore.
87
+
88
+ The archive is written to a temporary name and moved into place, so an
89
+ interrupted run cannot leave a half-written .zip shadowing a good one.
90
+ Note this always rewrites from scratch: unlike ``zip -FS`` sync mode it
91
+ recompresses unchanged files rather than copying them across.
92
+
93
+ Filenames are stored with the UTF-8 flag bit set, which ``zip`` does not
94
+ do - non-ASCII names come back correctly instead of as mojibake.
95
+ """
96
+ temp_archive = archive.with_name(archive.name + ".partial")
97
+
98
+ try:
99
+ with zipfile.ZipFile(
100
+ temp_archive, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9
101
+ ) as zf:
102
+ for source, arcname in _walk_entries(folder):
103
+ if verbose and source.is_file():
104
+ info(f" + {arcname}")
105
+ zf.write(source, arcname)
106
+ temp_archive.replace(archive)
107
+ except BaseException:
108
+ # Also fires on KeyboardInterrupt and SystemExit, deliberately: the
109
+ # point is to never leave a .partial behind. Always re-raises.
110
+ temp_archive.unlink(missing_ok=True)
111
+ raise
112
+
113
+
114
+ def main(argv: list[str] | None = None) -> int:
115
+ parser = build_parser(
116
+ prog="compress-folders",
117
+ description="📦 Create a max-compression .zip of every immediate subfolder of --path.",
118
+ )
119
+ parser.add_argument(
120
+ "--path", default="", metavar="DIR", help="Parent directory to zip subfolders of (required)"
121
+ )
122
+ add_bool_flag(parser, "--dry-run", help="Print what would be zipped, change nothing")
123
+ add_bool_flag(parser, "--verbose", help="Show per-file progress")
124
+ args = parser.parse_args(argv)
125
+
126
+ if not args.path:
127
+ warning("❌ --path is required")
128
+ parser.print_help()
129
+ return 1
130
+
131
+ root = scan_root(args.path)
132
+
133
+ header_suffix = " 🌵 (dry run)" if args.dry_run else ""
134
+ note("═══════════════════════════════════════════")
135
+ note(f"📦 compress-folders{header_suffix}")
136
+ note("═══════════════════════════════════════════")
137
+ note(f"Parent: {root}")
138
+
139
+ compressed = 0
140
+ skipped = 0
141
+
142
+ # Matches the shell version's `for dir in "$ROOT_DIR"/*/` glob: dotted
143
+ # directories are skipped (no `dotglob`), and a symlink to a directory IS
144
+ # included, because `*/` matches it.
145
+ folders = sorted(
146
+ path for path in root.iterdir() if path.is_dir() and not path.name.startswith(".")
147
+ )
148
+
149
+ for folder in folders:
150
+ archive = folder.with_name(folder.name + ".zip")
151
+
152
+ if args.dry_run:
153
+ info(f" 🪄 Would zip: {folder.name} → {folder.name}.zip")
154
+ compressed += 1
155
+ continue
156
+
157
+ info(f"📦 Compressing '{folder.name}'...")
158
+ try:
159
+ zip_folder(folder, archive, verbose=args.verbose)
160
+ except (OSError, ValueError) as exc:
161
+ # ValueError covers a file whose size changes mid-write and the
162
+ # UnicodeEncodeError raised for surrogate bytes in names off an
163
+ # SMB mount. The shell version isolated each folder in a subshell,
164
+ # so one bad folder never stopped the batch.
165
+ warning(f" ❌ Failed: {archive} ({exc})")
166
+ skipped += 1
167
+ continue
168
+ success(f" ✅ {folder.name}.zip")
169
+ compressed += 1
170
+
171
+ print()
172
+ note("─────────────────────────────────────────────")
173
+ if args.dry_run:
174
+ success(f"🌵 Would compress: {compressed} folder(s)")
175
+ log("💡 Dry run complete. Nothing was actually changed.")
176
+ return 0
177
+
178
+ success(f"✅ Compressed: {compressed}")
179
+ if skipped:
180
+ warning(f"❌ Failed: {skipped}")
181
+ return 1
182
+
183
+ log("🎉 Compression complete.")
184
+ return 0
185
+
186
+
187
+ if __name__ == "__main__":
188
+ run_cli(main)
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env python3
2
+ """🗑️ Delete every file under a path matching a set of extensions.
3
+
4
+ Usage:
5
+ delete-by-ext.py --path=/dir [--ext=jpg,png,mp4] [--dry-run] [--verbose]
6
+
7
+ Options:
8
+ --path=DIR Directory to scan (required)
9
+ --ext=LIST Comma-separated extensions, no dots. Glob metacharacters work,
10
+ so --ext=mp* matches mp4/mp3/mpg
11
+ (default: m3u,nfo,sfv,jpg,png,txt,log,cue,srr)
12
+ --dry-run Print what would be deleted, change nothing
13
+ --verbose Extra detail
14
+ --help Show help
15
+
16
+ Env:
17
+ DRY_RUN=false Opt out of the preview-only default (used by the .zshrc wrapper)
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import sys
23
+ from pathlib import Path
24
+
25
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
26
+
27
+ from utils import (
28
+ add_bool_flag,
29
+ build_parser,
30
+ env_bool,
31
+ info,
32
+ iter_files,
33
+ log,
34
+ normalize_extensions,
35
+ note,
36
+ run_cli,
37
+ scan_root,
38
+ warning,
39
+ )
40
+
41
+ __version__ = "2.2.0"
42
+
43
+ DEFAULT_EXTENSIONS = ("m3u", "nfo", "sfv", "jpg", "png", "txt", "log", "cue", "srr")
44
+
45
+
46
+ def main(argv: list[str] | None = None) -> int:
47
+ parser = build_parser(
48
+ prog="delete-by-ext",
49
+ description="🗑️ Delete files under a path matching a set of extensions.",
50
+ )
51
+ parser.add_argument("--path", default="", metavar="DIR", help="Directory to scan (required)")
52
+ parser.add_argument(
53
+ "--ext",
54
+ default=",".join(DEFAULT_EXTENSIONS),
55
+ metavar="LIST",
56
+ help=f"Comma-separated extensions (default: {','.join(DEFAULT_EXTENSIONS)})",
57
+ )
58
+ add_bool_flag(
59
+ parser,
60
+ "--dry-run",
61
+ default=env_bool("DRY_RUN", True),
62
+ help="Print what would be deleted, change nothing",
63
+ )
64
+ add_bool_flag(parser, "--verbose", help="Extra detail")
65
+
66
+ info(f"Running command in {Path.cwd()}")
67
+ args = parser.parse_args(argv)
68
+
69
+ extensions = normalize_extensions(args.ext)
70
+
71
+ note(f"Scanning: {args.path}")
72
+ note(f"Extensions: {' '.join(extensions)}")
73
+ note(f"Dry run: {str(args.dry_run).lower()}")
74
+ note(f"Verbose: {str(args.verbose).lower()}")
75
+ note("----------------------------------------------------")
76
+
77
+ if not extensions:
78
+ warning("--ext is required")
79
+ return 1
80
+
81
+ root = scan_root(args.path)
82
+
83
+ failures = 0
84
+
85
+ # macOS ._ sidecars are NOT skipped: the shell version deleted them when
86
+ # they matched, and leaving them orphaned after their parent file is gone
87
+ # is worse than deleting them.
88
+ for path in iter_files(root, extensions=extensions, skip_macos_metadata=False):
89
+ if args.dry_run:
90
+ info(f"[DRY RUN] Would delete: {path}")
91
+ continue
92
+
93
+ log(f"Deleting: {path}")
94
+ try:
95
+ path.unlink()
96
+ except OSError as exc:
97
+ # The shell version aborted the whole run here under `set -e`.
98
+ # Continuing and reporting at the end is more useful on a NAS
99
+ # where one busy file should not strand the rest of the sweep.
100
+ warning(f" ✗ Failed to delete {path}: {exc}")
101
+ failures += 1
102
+
103
+ log("Completed.")
104
+ return 1 if failures else 0
105
+
106
+
107
+ if __name__ == "__main__":
108
+ run_cli(main)
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env python3
2
+ """🧹 Delete truly-empty directories under a path, cascading upwards.
3
+
4
+ Only *strictly* empty directories are removed. A folder holding nothing but
5
+ ``.DS_Store``, ``._*`` or ``.git`` is intentionally left alone - a recursive
6
+ delete would silently wipe them.
7
+
8
+ Usage:
9
+ delete-empty-folders.py --path=/dir [--dry-run] [--verbose]
10
+
11
+ Options:
12
+ --path=DIR Directory to scan (required)
13
+ --dry-run Print what would be deleted, change nothing
14
+ --verbose Report skipped non-empty directories too
15
+ --help Show help
16
+
17
+ Env:
18
+ DRY_RUN=false Opt out of the preview-only default (used by the .zshrc wrapper)
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
28
+
29
+ from utils import (
30
+ add_bool_flag,
31
+ build_parser,
32
+ env_bool,
33
+ info,
34
+ log,
35
+ note,
36
+ run_cli,
37
+ scan_root,
38
+ warning,
39
+ )
40
+
41
+ __version__ = "3.0.0"
42
+
43
+
44
+ def directories_deepest_first(root: Path) -> list[Path]:
45
+ """Every directory under ``root``, children before parents.
46
+
47
+ This is ``find -depth -type d``. The ordering is what makes cascading work:
48
+ emptying a leaf lets its parent become empty, and the parent is visited
49
+ afterwards. The whole tree is enumerated up front - exactly like ``find``
50
+ piping into a loop - and emptiness is re-checked at visit time against the
51
+ live filesystem, so a directory emptied earlier in the run is seen as
52
+ empty when its turn comes.
53
+ """
54
+
55
+ def _on_error(exc: OSError) -> None:
56
+ warning(f"find: {exc.filename}: {exc.strerror}")
57
+
58
+ found: list[Path] = []
59
+ for dirpath, _, _ in os.walk(root, topdown=False, followlinks=False, onerror=_on_error):
60
+ found.append(Path(dirpath))
61
+ return found
62
+
63
+
64
+ def is_strictly_empty(path: Path) -> bool:
65
+ """True only when the directory has no entries at all, hidden included."""
66
+ try:
67
+ next(path.iterdir())
68
+ except StopIteration:
69
+ return True
70
+ except OSError:
71
+ return False
72
+ return False
73
+
74
+
75
+ def main(argv: list[str] | None = None) -> int:
76
+ parser = build_parser(
77
+ prog="delete-empty-folders",
78
+ description="🧹 Delete truly-empty directories under a path, cascading upwards.",
79
+ )
80
+ parser.add_argument("--path", default="", metavar="DIR", help="Directory to scan (required)")
81
+ add_bool_flag(
82
+ parser,
83
+ "--dry-run",
84
+ default=env_bool("DRY_RUN", True),
85
+ help="Print what would be deleted, change nothing",
86
+ )
87
+ add_bool_flag(parser, "--verbose", help="Report skipped non-empty directories too")
88
+
89
+ info(f"Running command in {Path.cwd()}")
90
+ args = parser.parse_args(argv)
91
+
92
+ if not args.path:
93
+ warning("Error: --path is required.")
94
+ return 1
95
+
96
+ root = scan_root(args.path)
97
+
98
+ note(f"Searching in: {args.path}")
99
+ note(f"Dry run: {str(args.dry_run).lower()}")
100
+ note(f"Verbose: {str(args.verbose).lower()}")
101
+
102
+ # Compare resolved paths so `--path=foo` and `--path=foo/` protect the same
103
+ # directory regardless of how it was spelled.
104
+ root_resolved = root.resolve()
105
+ deleted_count = 0
106
+ failures = 0
107
+
108
+ for directory in directories_deepest_first(root):
109
+ if directory.resolve() == root_resolved:
110
+ continue # never touch the root the user pointed at
111
+
112
+ if not is_strictly_empty(directory):
113
+ if args.verbose:
114
+ note(f"Skipped (not empty): {directory}")
115
+ continue
116
+
117
+ if args.dry_run:
118
+ log(f"[DRY-RUN] Would delete: {directory}")
119
+ else:
120
+ try:
121
+ directory.rmdir()
122
+ except OSError as exc:
123
+ # The shell version aborted here under `set -e`; reporting and
124
+ # carrying on lets one unwritable directory not strand the rest.
125
+ warning(f" ✗ Failed to delete {directory}: {exc}")
126
+ failures += 1
127
+ continue
128
+ log(f"Deleted: {directory}")
129
+ deleted_count += 1
130
+
131
+ log(f"📊 Total deleted directories: {deleted_count}")
132
+ return 1 if failures else 0
133
+
134
+
135
+ if __name__ == "__main__":
136
+ run_cli(main)
@@ -0,0 +1,105 @@
1
+ #!/usr/bin/env python3
2
+ """🧽 Delete the ``.smbdelete*`` turds an SMB share leaves behind.
3
+
4
+ Usage:
5
+ delete-smb-files.py --path=/dir [--dry-run]
6
+
7
+ Options:
8
+ --path=DIR Directory to scan (required)
9
+ --dry-run List what would be deleted, change nothing
10
+ --help Show help
11
+
12
+ Env:
13
+ DRY_RUN=false Opt out of the preview-only default (used by the .zshrc wrapper)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
22
+
23
+ from utils import (
24
+ add_bool_flag,
25
+ build_parser,
26
+ env_bool,
27
+ info,
28
+ iter_files,
29
+ log,
30
+ note,
31
+ run_cli,
32
+ scan_root,
33
+ warning,
34
+ )
35
+
36
+ __version__ = "2.1.0"
37
+
38
+ PREFIX = ".smbdelete"
39
+
40
+
41
+ def main(argv: list[str] | None = None) -> int:
42
+ parser = build_parser(
43
+ prog="delete-smb-files",
44
+ description="🧽 Delete .smbdelete* files left behind by an SMB share.",
45
+ )
46
+ parser.add_argument("--path", default="", metavar="DIR", help="Directory to scan (required)")
47
+ add_bool_flag(
48
+ parser,
49
+ "--dry-run",
50
+ default=env_bool("DRY_RUN", True),
51
+ help="List what would be deleted, change nothing",
52
+ )
53
+
54
+ info(f"Running command in {Path.cwd()}")
55
+ args = parser.parse_args(argv)
56
+
57
+ note(f"Searching in: {args.path}")
58
+ note(f"Dry run: {str(args.dry_run).lower()}")
59
+
60
+ if not args.path:
61
+ warning("Error: --path is required.")
62
+ warning("Usage: delete-smb-files.py --path=/directory/to/scan")
63
+ return 1
64
+
65
+ root = scan_root(args.path)
66
+
67
+ info(f"Scanning for .smbdelete* files under: {args.path}")
68
+
69
+ if args.dry_run:
70
+ info("Dry run mode: no files will be deleted.")
71
+
72
+ deleted = 0
73
+ failures = 0
74
+
75
+ # These sidecars are hidden files, so hidden entries must NOT be skipped.
76
+ # `._*` filtering is likewise off - a `._.smbdeleteX` is still junk.
77
+ for path in iter_files(root, skip_macos_metadata=False):
78
+ if not path.name.startswith(PREFIX):
79
+ continue
80
+
81
+ print(path, flush=True)
82
+ if args.dry_run:
83
+ continue
84
+
85
+ try:
86
+ path.unlink()
87
+ except OSError as exc:
88
+ # A file held open by the SMB client fails with "Resource busy".
89
+ # The shell version swallowed these with `2>/dev/null || true`;
90
+ # naming them is strictly more useful and still non-fatal.
91
+ warning(f" ✗ Could not delete {path}: {exc}")
92
+ failures += 1
93
+ continue
94
+ deleted += 1
95
+
96
+ if not args.dry_run:
97
+ # Summary line the shell version did not print; a sweep that silently
98
+ # deleted nothing is indistinguishable from one that could not run.
99
+ log(f"📊 Deleted {deleted} file(s).")
100
+
101
+ return 1 if failures else 0
102
+
103
+
104
+ if __name__ == "__main__":
105
+ run_cli(main)
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env python3
2
+ """📉 Find (and optionally delete) video files at or under a size threshold.
3
+
4
+ Usage:
5
+ files-under-size.py --path=/dir --size=1MB [--dry-run]
6
+
7
+ Options:
8
+ --path=DIR Directory to scan (required)
9
+ --size=SIZE Threshold, e.g. 500k / 1MB / 2GiB (required, binary units,
10
+ bytes through gigabytes only)
11
+ --dry-run List what would be deleted, change nothing
12
+ --help Show help
13
+
14
+ Env:
15
+ DRY_RUN=false Opt out of the preview-only default (used by the .zshrc wrapper)
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
24
+
25
+ from utils import (
26
+ add_bool_flag,
27
+ build_parser,
28
+ env_bool,
29
+ format_bytes,
30
+ info,
31
+ iter_files,
32
+ log,
33
+ note,
34
+ parse_size,
35
+ run_cli,
36
+ scan_root,
37
+ warning,
38
+ )
39
+
40
+ __version__ = "2.2.0"
41
+
42
+ VIDEO_EXTENSIONS = (
43
+ "mp4",
44
+ "mkv",
45
+ "avi",
46
+ "mov",
47
+ "flv",
48
+ "wmv",
49
+ "webm",
50
+ "mpeg",
51
+ "mpg",
52
+ "m4v",
53
+ )
54
+
55
+
56
+ def main(argv: list[str] | None = None) -> int:
57
+ parser = build_parser(
58
+ prog="files-under-size",
59
+ description="📉 Find video files at or under a size threshold.",
60
+ )
61
+ parser.add_argument("--path", default="", metavar="DIR", help="Directory to scan (required)")
62
+ parser.add_argument("--size", default="", metavar="SIZE", help="Threshold, e.g. 1MB (required)")
63
+ add_bool_flag(
64
+ parser,
65
+ "--dry-run",
66
+ default=env_bool("DRY_RUN", True),
67
+ help="List what would be deleted, change nothing",
68
+ )
69
+
70
+ info(f"Running command in {Path.cwd()}")
71
+ args = parser.parse_args(argv)
72
+
73
+ note(f"Scanning: {args.path}")
74
+ note(f"Size threshold: {args.size}")
75
+ note(f"Dry run: {str(args.dry_run).lower()}")
76
+ note("----------------------------------------------------")
77
+
78
+ if not args.size or not args.path:
79
+ warning("❌ --size and --path are required")
80
+ warning("📋 Usage: files-under-size.py --path=/dir --size=1MB [--dry-run]")
81
+ return 1
82
+
83
+ try:
84
+ limit_bytes = parse_size(args.size)
85
+ except ValueError as exc:
86
+ warning(f"❌ {exc}")
87
+ return 1
88
+
89
+ root = scan_root(args.path)
90
+
91
+ # The shell version used `find -size -Nc` with N = limit + 1, which selects
92
+ # files of AT MOST `limit` bytes. Keep the inclusive comparison.
93
+ matches: list[tuple[Path, int]] = []
94
+ for path in iter_files(root, extensions=VIDEO_EXTENSIONS, skip_macos_metadata=False):
95
+ try:
96
+ size = path.stat().st_size
97
+ except OSError:
98
+ continue
99
+ if size <= limit_bytes:
100
+ matches.append((path, size))
101
+
102
+ total = len(matches)
103
+ if total == 0:
104
+ log(f"No matching video files ≤ {args.size} ({limit_bytes} bytes).")
105
+ return 0
106
+
107
+ info(f"Found {total} file(s) ≤ {args.size} ({limit_bytes} bytes)")
108
+ note(f"Threshold: {limit_bytes:,} bytes ({args.size})")
109
+
110
+ failures = 0
111
+
112
+ for index, (path, size) in enumerate(matches, start=1):
113
+ log(f"File size: {format_bytes(size)}")
114
+ if args.dry_run:
115
+ warning(f"[{index}/{total}] Would delete: {format_bytes(size)} - {path}")
116
+ continue
117
+
118
+ warning(f"[{index}/{total}] Deleting: {format_bytes(size)} - {path}")
119
+ try:
120
+ path.unlink()
121
+ except OSError as exc:
122
+ # The shell version aborted the run here; reporting and continuing
123
+ # means one busy file cannot strand the rest of the sweep.
124
+ warning(f" ✗ Failed to delete {path}: {exc}")
125
+ failures += 1
126
+
127
+ log("Done.")
128
+ return 1 if failures else 0
129
+
130
+
131
+ if __name__ == "__main__":
132
+ run_cli(main)