@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.
- package/README.md +58 -26
- package/bin/art.py +22 -20
- package/bin/branchsync.py +324 -0
- package/bin/che.py +208 -587
- package/bin/commands.py +219 -231
- package/bin/doctor.py +220 -0
- package/bin/drives/eject-all-drives.py +21 -38
- package/bin/drives/mount-all-drives.py +18 -62
- package/bin/drives/ping-nas.py +77 -105
- package/bin/files/compress-folders.py +7 -11
- package/bin/files/delete-by-ext.py +10 -14
- package/bin/files/delete-empty-folders.py +33 -37
- package/bin/files/delete-smb-files.py +12 -21
- package/bin/files/files-under-size.py +5 -11
- package/bin/files/find-largest-files.py +21 -25
- package/bin/files/list-permission.py +40 -0
- package/bin/files/make-alpha-dir.py +2 -0
- package/bin/git/all-actions.py +350 -253
- package/bin/git/checkout-my-branches.py +42 -63
- package/bin/git/clean-stale-branches.py +23 -48
- package/bin/git/prune-worktrees.py +12 -51
- package/bin/git/sync-all-branches.py +75 -297
- package/bin/git/update-local-branches.py +67 -73
- package/bin/gitutils.py +263 -0
- package/bin/install.py +393 -1182
- package/bin/machine.py +345 -0
- package/bin/media.py +127 -0
- package/bin/menu.py +437 -0
- package/bin/nas.py +102 -0
- package/bin/rcfile.py +380 -0
- package/bin/shellgen.py +503 -431
- package/bin/system/btop-launch.py +17 -13
- package/bin/system/update-brew.py +57 -68
- package/bin/tui.py +156 -153
- package/bin/utils.py +165 -237
- package/bin/video/delete-duplicate-videos.py +64 -62
- package/bin/video/detect-green-magenta-videos.py +105 -95
- package/bin/video/find-movie-by-year.py +3 -0
- package/bin/video/find-video-mkv-issues.py +48 -65
- package/bin/video/fix-codecs.py +25 -30
- package/bin/video/largest-tv-shows.py +60 -57
- package/bin/video/remove-metadata.py +3 -6
- package/bin/video/rename-video-file.py +74 -84
- package/bin/video/scan-videos-audio-language.py +16 -33
- package/bin/video/show-codecs.py +20 -24
- package/bin/video/validate-video-files.py +5 -8
- package/bin/video/video-list.py +17 -20
- package/package.json +4 -10
- package/shell/che.bash +33 -33
- package/shell/che.fish +7 -7
- package/shell/che.zsh +33 -33
- package/shell/completions/_che +19 -19
- package/shell/completions/che.bash +15 -15
- package/shell/completions/che.fish +34 -50
package/bin/drives/ping-nas.py
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""📡 Keep-alive pinger for the NAS drives.
|
|
3
3
|
|
|
4
|
-
Each cycle
|
|
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
|
-
|
|
43
|
+
parse_count,
|
|
42
44
|
run,
|
|
43
45
|
run_cli,
|
|
44
46
|
success,
|
|
45
47
|
warning,
|
|
46
48
|
)
|
|
47
49
|
|
|
48
|
-
__version__ = "3.
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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[
|
|
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
|
|
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 #{
|
|
98
|
+
log(f"🔄 Cycle #{cycle} — checking {len(active)} NAS drive(s)...")
|
|
111
99
|
|
|
112
|
-
for
|
|
113
|
-
if not
|
|
114
|
-
warning(f" ❌ {name} ({ip})
|
|
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,
|
|
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
|
|
162
|
-
"/Volumes. A drive that is reachable but has
|
|
163
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
200
|
-
|
|
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 =
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
note("─────────────────────────────────────────────")
|
|
168
|
+
footer_rule()
|
|
173
169
|
if args.dry_run:
|
|
174
170
|
success(f"🌵 Would compress: {compressed} folder(s)")
|
|
175
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
51
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
65
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
#
|
|
103
|
-
#
|
|
104
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
#
|
|
76
|
-
# `._*`
|
|
77
|
-
for path in iter_files(root
|
|
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
|
-
|
|
86
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.")
|