@codebend3r/che 1.1.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 +38 -24
  2. package/bin/art.py +22 -20
  3. package/bin/che.py +208 -587
  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 -1182
  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 +503 -431
  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
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env python3
2
2
  """📡 Keep-alive pinger for the NAS drives.
3
3
 
4
- Each cycle pings every host and verifies it is still mounted under /Volumes.
4
+ Each cycle probes every host's SMB port and verifies it is still mounted under
5
+ /Volumes. The probe is a TCP connect to port 445 rather than ICMP, because the
6
+ Synology firewalls drop ICMP echo while still serving SMB.
7
+
5
8
  If a drive is reachable but the share has dropped off /Volumes (eject, network
6
9
  blip, sleep) it is silently remounted via AppleScript's ``mount volume`` using
7
10
  Keychain credentials, with no Finder modals.
@@ -23,108 +26,93 @@ Options:
23
26
 
24
27
  from __future__ import annotations
25
28
 
26
- import os
27
- import re
28
29
  import signal
29
30
  import sys
30
31
  import time
31
32
  from pathlib import Path
32
- from types import FrameType
33
33
 
34
34
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
35
35
 
36
+ from nas import SMB_USER, NasDrive, is_mounted, mount_smb, select_drives, smb_url
36
37
  from utils import (
37
38
  add_bool_flag,
38
39
  build_parser,
40
+ heading,
39
41
  info,
40
42
  log,
41
- note,
43
+ parse_count,
42
44
  run,
43
45
  run_cli,
44
46
  success,
45
47
  warning,
46
48
  )
47
49
 
48
- __version__ = "3.3.0"
49
-
50
- SMB_USER = "crivas"
51
-
52
- DRIVES: tuple[tuple[str, str], ...] = (
53
- ("Meleys", "192.168.50.2"),
54
- ("Vermithor", "192.168.50.3"),
55
- ("Caraxes", "192.168.50.4"),
56
- ("Syrax", "192.168.50.5"),
57
- ("Vhagar", "192.168.50.6"),
58
- )
59
-
60
- _cycle = 0
50
+ __version__ = "3.4.0"
61
51
 
52
+ SMB_PORT = 445
62
53
 
63
- def is_mounted(name: str) -> bool:
64
- """True when something is actually mounted at ``/Volumes/<name>``."""
65
- return os.path.ismount(f"/Volumes/{name}")
66
54
 
55
+ def smb_reachable(ip: str, timeout: int) -> bool:
56
+ """One TCP connect to the SMB port with a per-host timeout in seconds.
67
57
 
68
- def ping_host(ip: str, timeout: int) -> bool:
69
- """One ICMP probe with a per-host timeout. macOS ``ping -t`` is seconds."""
70
- completed = run(["ping", "-c", "1", "-t", str(timeout), ip], check=False, capture=True)
58
+ ICMP is useless here: the NAS firewalls drop echo requests, so ``ping``
59
+ reported every drive unreachable while the shares were mounted fine. The
60
+ probe goes through Apple's ``/usr/bin/nc`` rather than a Python socket
61
+ because macOS Local Network privacy can deny LAN connections from a
62
+ Homebrew Python (``EHOSTUNREACH``) while exempting system binaries.
63
+ """
64
+ completed = run(
65
+ ["/usr/bin/nc", "-z", "-G", str(timeout), ip, str(SMB_PORT)],
66
+ check=False,
67
+ capture=True,
68
+ )
71
69
  return completed.returncode == 0
72
70
 
73
71
 
74
72
  def remount_drive(name: str, host: str) -> bool:
75
73
  """Remount one drive via AppleScript. True on success."""
76
- url = f"smb://{SMB_USER}@{host}/{name}"
77
- log(f" 🔗 Remounting {name} ({url})...")
78
-
79
- completed = run(["osascript", "-e", f'mount volume "{url}"'], check=False, capture=True)
80
- if completed.returncode == 0:
81
- success(f" ✅ {name} remounted 🐉")
82
- return True
74
+ log(f" 🔗 Remounting {name} ({smb_url(name, host)})...")
75
+ if not mount_smb(name, host):
76
+ return False
83
77
 
84
- warning(f" ❌ mount failed for {url}")
85
- stderr = (completed.stderr or "").strip()
86
- if stderr:
87
- warning(f" {stderr}")
88
- warning(f" Check that Keychain has a password for '{SMB_USER}@{host}'.")
89
- return False
78
+ success(f" ✅ {name} remounted 🐉")
79
+ return True
90
80
 
91
81
 
92
82
  def ping_all_nas(
93
- active: list[tuple[str, str]],
83
+ active: list[NasDrive],
94
84
  *,
85
+ cycle: int,
95
86
  ping_timeout: int,
96
87
  remount: bool,
97
88
  use_ip: bool,
98
89
  quiet: bool,
99
90
  ) -> None:
100
- """Run one full check cycle over the active drive list."""
101
- global _cycle
102
- _cycle += 1
103
-
91
+ """Run check cycle number ``cycle`` over the active drive list."""
104
92
  unreachable = 0
105
93
  unmounted = 0
106
94
  remounted = 0
107
95
  remount_failed = 0
108
96
 
109
97
  if not quiet:
110
- log(f"🔄 Cycle #{_cycle} — checking {len(active)} NAS drive(s)...")
98
+ log(f"🔄 Cycle #{cycle} — checking {len(active)} NAS drive(s)...")
111
99
 
112
- for name, ip in active:
113
- if not ping_host(ip, ping_timeout):
114
- warning(f" ❌ {name} ({ip}) — ping failed")
100
+ for drive in active:
101
+ if not smb_reachable(drive.ip, ping_timeout):
102
+ warning(f" ❌ {drive.name} ({drive.ip}): SMB port {SMB_PORT} unreachable")
115
103
  unreachable += 1
116
104
  continue
117
105
 
118
- if is_mounted(name):
106
+ if is_mounted(drive.name):
119
107
  if not quiet:
120
- log(f" ✅ 🐉 {name} ({ip}) — mounted")
108
+ log(f" ✅ 🐉 {drive.name} ({drive.ip}) — mounted")
121
109
  continue
122
110
 
123
- warning(f" ⚠️ {name} ({ip}) — reachable but not mounted")
111
+ warning(f" ⚠️ {drive.name} ({drive.ip}) — reachable but not mounted")
124
112
  unmounted += 1
125
113
 
126
114
  if remount:
127
- if remount_drive(name, ip if use_ip else name):
115
+ if remount_drive(drive.name, drive.host(use_ip=use_ip)):
128
116
  remounted += 1
129
117
  else:
130
118
  remount_failed += 1
@@ -146,32 +134,23 @@ def ping_all_nas(
146
134
  warning(f"⚠️ Cycle summary: {' '.join(parts)}")
147
135
 
148
136
 
149
- def _shutdown(signum: int, frame: FrameType | None) -> None:
150
- """Exit 0 on Ctrl-C, matching the shell version's INT/TERM trap."""
151
- print()
152
- info(f"👋 Stopping keep-alive pinger after {_cycle} cycle(s).")
153
- sys.exit(0)
154
-
155
-
156
137
  def main(argv: list[str] | None = None) -> int:
157
138
  parser = build_parser(
158
139
  prog="ping-nas",
159
140
  description="📡 Keep-alive pinger for the NAS drives.",
160
141
  epilog=(
161
- "Each cycle pings every host and verifies it is still mounted under\n"
162
- "/Volumes. A drive that is reachable but has dropped off /Volumes is\n"
163
- f"silently remounted via AppleScript as '{SMB_USER}' using Keychain\n"
164
- "credentials, with no Finder modals."
142
+ f"Each cycle probes every host's SMB port ({SMB_PORT}) and verifies it is\n"
143
+ "still mounted under /Volumes. A drive that is reachable but has\n"
144
+ "dropped off /Volumes is silently remounted via AppleScript as\n"
145
+ f"'{SMB_USER}' using Keychain credentials, with no Finder modals."
165
146
  ),
166
147
  )
167
- # --interval and --ping-timeout stay strings so the shell version's own
168
- # message survives; `type=int` would let argparse own the failure first.
148
+ # Strings, validated by parse_count below rather than `type=int`.
169
149
  parser.add_argument(
170
150
  "--interval", default="300", metavar="N", help="Seconds between cycles (default: 300)"
171
151
  )
172
152
  parser.add_argument(
173
153
  "--ping-timeout",
174
- dest="ping_timeout",
175
154
  default="2",
176
155
  metavar="N",
177
156
  help="Per-host ping timeout in seconds (default: 2)",
@@ -182,61 +161,54 @@ def main(argv: list[str] | None = None) -> int:
182
161
  add_bool_flag(
183
162
  parser,
184
163
  "--no-remount",
185
- dest="no_remount",
186
164
  allow_value=False,
187
165
  help="Detect-only: warn about missing mounts, don't remount",
188
166
  )
189
- add_bool_flag(
190
- parser,
191
- "--use-ip",
192
- dest="use_ip",
193
- help="Remount via //USER@IP/NAME instead of //USER@NAME/NAME",
194
- )
167
+ add_bool_flag(parser, "--use-ip", help="Remount via //USER@IP/NAME instead of //USER@NAME/NAME")
195
168
  add_bool_flag(parser, "--once", help="Run a single cycle and exit (useful from cron)")
196
169
  add_bool_flag(parser, "--quiet", help="Only log failures, remount attempts, and cycle summary")
197
170
  args = parser.parse_args(argv)
198
171
 
199
- values: dict[str, int] = {}
200
- for flag, raw in (("--interval", args.interval), ("--ping-timeout", args.ping_timeout)):
201
- if not re.fullmatch(r"[0-9]+", raw) or int(raw) < 1:
202
- warning(f"❌ {flag} must be a positive integer (got: {raw})")
203
- return 1
204
- values[flag] = int(raw)
205
- interval = values["--interval"]
206
- ping_timeout = values["--ping-timeout"]
172
+ interval = parse_count(args.interval, "--interval", minimum=1)
173
+ ping_timeout = parse_count(args.ping_timeout, "--ping-timeout", minimum=1)
207
174
 
208
- active = [(n, ip) for n, ip in DRIVES if not args.only or n.lower() == args.only.lower()]
175
+ active = select_drives(args.only)
209
176
  if not active:
210
177
  warning(f"❌ No drives matched --only={args.only}")
211
178
  return 1
212
179
 
213
- signal.signal(signal.SIGINT, _shutdown)
214
- signal.signal(signal.SIGTERM, _shutdown)
215
-
216
- note("═══════════════════════════════════════════")
217
- note("📡 ping-nas")
218
- note("═══════════════════════════════════════════")
219
-
220
- cycle_kwargs = {
221
- "ping_timeout": ping_timeout,
222
- "remount": not args.no_remount,
223
- "use_ip": args.use_ip,
224
- "quiet": args.quiet,
225
- }
226
-
227
- if args.once:
228
- ping_all_nas(active, **cycle_kwargs)
180
+ # INT and TERM both stop the pinger with exit 0, matching the shell
181
+ # version's trap. SIGINT is set too, not left to Python's default, because
182
+ # a pinger started in the background by a script inherits it ignored.
183
+ for signum in (signal.SIGINT, signal.SIGTERM):
184
+ signal.signal(signum, signal.default_int_handler)
185
+ cycle = 0
186
+ try:
187
+ heading("📡 ping-nas")
188
+ if not args.once:
189
+ info(
190
+ f"🚀 Keep-alive pinger started for {len(active)} NAS "
191
+ f"(every {interval}s). Press Ctrl-C to stop."
192
+ )
193
+
194
+ while True:
195
+ cycle += 1
196
+ ping_all_nas(
197
+ active,
198
+ cycle=cycle,
199
+ ping_timeout=ping_timeout,
200
+ remount=not args.no_remount,
201
+ use_ip=args.use_ip,
202
+ quiet=args.quiet,
203
+ )
204
+ if args.once:
205
+ return 0
206
+ time.sleep(interval)
207
+ except KeyboardInterrupt:
208
+ print()
209
+ info(f"👋 Stopping keep-alive pinger after {cycle} cycle(s).")
229
210
  return 0
230
211
 
231
- info(
232
- f"🚀 Keep-alive pinger started for {len(active)} NAS "
233
- f"(every {interval}s). Press Ctrl-C to stop."
234
- )
235
-
236
- while True:
237
- ping_all_nas(active, **cycle_kwargs)
238
- time.sleep(interval)
239
-
240
212
 
241
213
  if __name__ == "__main__":
242
214
  run_cli(main)
@@ -25,6 +25,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
25
25
  from utils import (
26
26
  add_bool_flag,
27
27
  build_parser,
28
+ dry_run_complete,
29
+ footer_rule,
30
+ heading,
28
31
  info,
29
32
  log,
30
33
  note,
@@ -67,10 +70,7 @@ def _walk_entries(folder: Path) -> list[tuple[Path, str]]:
67
70
  dirnames.sort()
68
71
  filenames.sort()
69
72
 
70
- for name in dirnames:
71
- child = directory / name
72
- entries.append((child, str(child.relative_to(parent))))
73
- for name in filenames:
73
+ for name in (*dirnames, *filenames):
74
74
  child = directory / name
75
75
  entries.append((child, str(child.relative_to(parent))))
76
76
 
@@ -130,10 +130,7 @@ def main(argv: list[str] | None = None) -> int:
130
130
 
131
131
  root = scan_root(args.path)
132
132
 
133
- header_suffix = " 🌵 (dry run)" if args.dry_run else ""
134
- note("═══════════════════════════════════════════")
135
- note(f"📦 compress-folders{header_suffix}")
136
- note("═══════════════════════════════════════════")
133
+ heading("📦 compress-folders", dry_run=args.dry_run)
137
134
  note(f"Parent: {root}")
138
135
 
139
136
  compressed = 0
@@ -168,11 +165,10 @@ def main(argv: list[str] | None = None) -> int:
168
165
  success(f" ✅ {folder.name}.zip")
169
166
  compressed += 1
170
167
 
171
- print()
172
- note("─────────────────────────────────────────────")
168
+ footer_rule()
173
169
  if args.dry_run:
174
170
  success(f"🌵 Would compress: {compressed} folder(s)")
175
- log("💡 Dry run complete. Nothing was actually changed.")
171
+ dry_run_complete()
176
172
  return 0
177
173
 
178
174
  success(f"✅ Compressed: {compressed}")
@@ -14,7 +14,7 @@ Options:
14
14
  --help Show help
15
15
 
16
16
  Env:
17
- DRY_RUN=false Opt out of the preview-only default (used by the .zshrc wrapper)
17
+ DRY_RUN=false Opt out of the preview-only default (used by the generated wrapper)
18
18
  """
19
19
 
20
20
  from __future__ import annotations
@@ -32,7 +32,8 @@ from utils import (
32
32
  iter_files,
33
33
  log,
34
34
  normalize_extensions,
35
- note,
35
+ note_settings,
36
+ remove_or_warn,
36
37
  run_cli,
37
38
  scan_root,
38
39
  warning,
@@ -68,11 +69,12 @@ def main(argv: list[str] | None = None) -> int:
68
69
 
69
70
  extensions = normalize_extensions(args.ext)
70
71
 
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("----------------------------------------------------")
72
+ note_settings(
73
+ ("Scanning", args.path),
74
+ ("Extensions", " ".join(extensions)),
75
+ ("Dry run", args.dry_run),
76
+ ("Verbose", args.verbose),
77
+ )
76
78
 
77
79
  if not extensions:
78
80
  warning("--ext is required")
@@ -91,13 +93,7 @@ def main(argv: list[str] | None = None) -> int:
91
93
  continue
92
94
 
93
95
  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}")
96
+ if not remove_or_warn(path):
101
97
  failures += 1
102
98
 
103
99
  log("Completed.")
@@ -15,13 +15,14 @@ Options:
15
15
  --help Show help
16
16
 
17
17
  Env:
18
- DRY_RUN=false Opt out of the preview-only default (used by the .zshrc wrapper)
18
+ DRY_RUN=false Opt out of the preview-only default (used by the generated wrapper)
19
19
  """
20
20
 
21
21
  from __future__ import annotations
22
22
 
23
23
  import os
24
24
  import sys
25
+ from collections.abc import Container
25
26
  from pathlib import Path
26
27
 
27
28
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
@@ -33,12 +34,14 @@ from utils import (
33
34
  info,
34
35
  log,
35
36
  note,
37
+ note_settings,
38
+ remove_or_warn,
39
+ report_walk_error,
36
40
  run_cli,
37
41
  scan_root,
38
- warning,
39
42
  )
40
43
 
41
- __version__ = "3.0.0"
44
+ __version__ = "3.1.0"
42
45
 
43
46
 
44
47
  def directories_deepest_first(root: Path) -> list[Path]:
@@ -47,29 +50,23 @@ def directories_deepest_first(root: Path) -> list[Path]:
47
50
  This is ``find -depth -type d``. The ordering is what makes cascading work:
48
51
  emptying a leaf lets its parent become empty, and the parent is visited
49
52
  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
+ piping into a loop - and emptiness is re-checked at visit time, so a
54
+ directory emptied earlier in the run is seen as empty when its turn comes.
53
55
  """
56
+ walk = os.walk(root, topdown=False, followlinks=False, onerror=report_walk_error)
57
+ return [Path(dirpath) for dirpath, _, _ in walk]
54
58
 
55
- def _on_error(exc: OSError) -> None:
56
- warning(f"find: {exc.filename}: {exc.strerror}")
57
59
 
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
60
+ def is_strictly_empty(path: Path, gone: Container[Path]) -> bool:
61
+ """True only when the directory has no entries at all, hidden included.
62
62
 
63
-
64
- def is_strictly_empty(path: Path) -> bool:
65
- """True only when the directory has no entries at all, hidden included."""
63
+ Entries in ``gone`` do not count: they are the directories a dry run has
64
+ already reported, which a real run would have removed by now.
65
+ """
66
66
  try:
67
- next(path.iterdir())
68
- except StopIteration:
69
- return True
67
+ return all(entry in gone for entry in path.iterdir())
70
68
  except OSError:
71
69
  return False
72
- return False
73
70
 
74
71
 
75
72
  def main(argv: list[str] | None = None) -> int:
@@ -89,40 +86,39 @@ def main(argv: list[str] | None = None) -> int:
89
86
  info(f"Running command in {Path.cwd()}")
90
87
  args = parser.parse_args(argv)
91
88
 
92
- if not args.path:
93
- warning("Error: --path is required.")
94
- return 1
95
-
89
+ # An empty --path reads "--path is required", where the shell version
90
+ # printed "Error: --path is required.".
96
91
  root = scan_root(args.path)
97
92
 
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()}")
93
+ note_settings(
94
+ ("Searching in", args.path),
95
+ ("Dry run", args.dry_run),
96
+ ("Verbose", args.verbose),
97
+ rule=False,
98
+ )
101
99
 
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()
100
+ # DELIBERATE DIVERGENCE: a dry run counts the directories it reports as
101
+ # already gone, so `a/b/c` previews all three. The shell version (and
102
+ # this one, until 3.1.0) re-checked the untouched filesystem and previewed
103
+ # only the leaf, under-reporting what the real run then deleted.
104
+ would_delete: set[Path] = set()
105
105
  deleted_count = 0
106
106
  failures = 0
107
107
 
108
108
  for directory in directories_deepest_first(root):
109
- if directory.resolve() == root_resolved:
109
+ if directory == root:
110
110
  continue # never touch the root the user pointed at
111
111
 
112
- if not is_strictly_empty(directory):
112
+ if not is_strictly_empty(directory, would_delete):
113
113
  if args.verbose:
114
114
  note(f"Skipped (not empty): {directory}")
115
115
  continue
116
116
 
117
117
  if args.dry_run:
118
118
  log(f"[DRY-RUN] Would delete: {directory}")
119
+ would_delete.add(directory)
119
120
  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}")
121
+ if not remove_or_warn(directory, Path.rmdir):
126
122
  failures += 1
127
123
  continue
128
124
  log(f"Deleted: {directory}")
@@ -10,7 +10,7 @@ Options:
10
10
  --help Show help
11
11
 
12
12
  Env:
13
- DRY_RUN=false Opt out of the preview-only default (used by the .zshrc wrapper)
13
+ DRY_RUN=false Opt out of the preview-only default (used by the generated wrapper)
14
14
  """
15
15
 
16
16
  from __future__ import annotations
@@ -27,13 +27,13 @@ from utils import (
27
27
  info,
28
28
  iter_files,
29
29
  log,
30
- note,
30
+ note_settings,
31
+ remove_or_warn,
31
32
  run_cli,
32
33
  scan_root,
33
- warning,
34
34
  )
35
35
 
36
- __version__ = "2.1.0"
36
+ __version__ = "2.1.1"
37
37
 
38
38
  PREFIX = ".smbdelete"
39
39
 
@@ -54,14 +54,10 @@ def main(argv: list[str] | None = None) -> int:
54
54
  info(f"Running command in {Path.cwd()}")
55
55
  args = parser.parse_args(argv)
56
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
57
+ note_settings(("Searching in", args.path), ("Dry run", args.dry_run), rule=False)
64
58
 
59
+ # An empty --path reads "--path is required", where the shell version
60
+ # printed "Error: --path is required." and a usage line.
65
61
  root = scan_root(args.path)
66
62
 
67
63
  info(f"Scanning for .smbdelete* files under: {args.path}")
@@ -72,9 +68,9 @@ def main(argv: list[str] | None = None) -> int:
72
68
  deleted = 0
73
69
  failures = 0
74
70
 
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):
71
+ # Case-sensitive prefix, as `find -name '.smbdelete*'` matched. That never
72
+ # reached a `._.smbdelete*` AppleDouble sidecar, and neither does this.
73
+ for path in iter_files(root):
78
74
  if not path.name.startswith(PREFIX):
79
75
  continue
80
76
 
@@ -82,13 +78,8 @@ def main(argv: list[str] | None = None) -> int:
82
78
  if args.dry_run:
83
79
  continue
84
80
 
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}")
81
+ # A file held open by the SMB client fails with "Resource busy".
82
+ if not remove_or_warn(path):
92
83
  failures += 1
93
84
  continue
94
85
  deleted += 1
@@ -12,7 +12,7 @@ Options:
12
12
  --help Show help
13
13
 
14
14
  Env:
15
- DRY_RUN=false Opt out of the preview-only default (used by the .zshrc wrapper)
15
+ DRY_RUN=false Opt out of the preview-only default (used by the generated wrapper)
16
16
  """
17
17
 
18
18
  from __future__ import annotations
@@ -31,7 +31,9 @@ from utils import (
31
31
  iter_files,
32
32
  log,
33
33
  note,
34
+ note_settings,
34
35
  parse_size,
36
+ remove_or_warn,
35
37
  run_cli,
36
38
  scan_root,
37
39
  warning,
@@ -70,10 +72,7 @@ def main(argv: list[str] | None = None) -> int:
70
72
  info(f"Running command in {Path.cwd()}")
71
73
  args = parser.parse_args(argv)
72
74
 
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("----------------------------------------------------")
75
+ note_settings(("Scanning", args.path), ("Size threshold", args.size), ("Dry run", args.dry_run))
77
76
 
78
77
  if not args.size or not args.path:
79
78
  warning("❌ --size and --path are required")
@@ -116,12 +115,7 @@ def main(argv: list[str] | None = None) -> int:
116
115
  continue
117
116
 
118
117
  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}")
118
+ if not remove_or_warn(path):
125
119
  failures += 1
126
120
 
127
121
  log("Done.")