-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathDeleteMyFiles.py
More file actions
323 lines (269 loc) · 10.1 KB
/
Copy pathDeleteMyFiles.py
File metadata and controls
323 lines (269 loc) · 10.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
"""Delete files with a given extension from a folder.
Deleted files go to the system trash by default, so mistakes are
recoverable. Use --permanent to bypass the trash and delete for real.
Usage (non-interactive):
python DeleteMyFiles.py --folder ./Downloads --extension .tmp
python DeleteMyFiles.py --folder ./Downloads --extension .tmp,.log --recursive
python DeleteMyFiles.py --folder ./Downloads --extension .tmp --min-age-days 7
python DeleteMyFiles.py --folder ./Downloads --extension .tmp --exclude ".git" --exclude "*.bak"
python DeleteMyFiles.py --folder ./Downloads --extension .tmp --dry-run
python DeleteMyFiles.py --folder ./Downloads --extension .tmp --watch 3600 --yes
If --folder/--extension are omitted you'll be prompted interactively,
same as the original script. Either forward or backward slashes work,
on any OS.
"""
from __future__ import annotations
import argparse
import fnmatch
import logging
import sys
import time
from pathlib import Path
from typing import List, Optional, Set
log = logging.getLogger("delete-my-files")
def configure_logging(verbose: bool, log_file: Optional[str]) -> None:
log.setLevel(logging.DEBUG if verbose else logging.INFO)
log.handlers.clear()
console = logging.StreamHandler()
console.setLevel(logging.DEBUG if verbose else logging.INFO)
console.setFormatter(logging.Formatter("%(message)s"))
log.addHandler(console)
if log_file:
file_handler = logging.FileHandler(log_file, encoding="utf-8")
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(
logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
)
log.addHandler(file_handler)
SECONDS_PER_DAY = 86400
# Folders that are never a legitimate target for a bulk delete, even
# with --recursive. Resolved case-insensitively on Windows.
DANGEROUS_NAMES = {
"windows", "system32", "program files", "program files (x86)",
"programdata", "boot",
"etc", "usr", "bin", "sbin", "lib", "lib64", "var", "opt",
"system", "library",
}
def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("--folder", help="Folder to delete files from")
parser.add_argument(
"--extension",
help="File extension(s) to remove, comma-separated, e.g. .tmp,.log",
)
parser.add_argument(
"--recursive", action="store_true", help="Also search subfolders"
)
parser.add_argument(
"--exclude",
action="append",
default=[],
help="Glob pattern to exclude (matched against filename and path "
"segments, e.g. '.git' or '*.bak'). Can be passed multiple times.",
)
parser.add_argument(
"--min-age-days",
type=float,
default=None,
help="Only delete files last modified at least this many days ago",
)
parser.add_argument(
"--follow-symlinks",
action="store_true",
help="Include symlinks (skipped by default for safety)",
)
parser.add_argument(
"--permanent",
action="store_true",
help="Delete files permanently instead of moving them to the trash",
)
parser.add_argument(
"--force",
action="store_true",
help="Allow running against a folder that looks like a system/home directory",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Show what would be deleted without deleting anything",
)
parser.add_argument(
"--yes", action="store_true", help="Skip the confirmation prompt"
)
parser.add_argument(
"--watch",
type=float,
default=None,
metavar="SECONDS",
help="Run repeatedly, waiting this many seconds between runs, "
"until Ctrl+C. Requires --yes or --dry-run.",
)
parser.add_argument(
"--verbose",
action="store_true",
help="Show debug-level detail, including files skipped by "
"--exclude/--min-age-days/symlink filtering",
)
parser.add_argument(
"--log-file",
metavar="PATH",
help="Also write a timestamped log of this run to PATH",
)
args = parser.parse_args(argv)
if args.watch is not None and not (args.yes or args.dry_run):
parser.error("--watch requires --yes or --dry-run (it runs unattended)")
return args
def prompt_for_folder() -> Path:
while True:
raw = input("Paste or type the folder path (or 'quit' to exit): ").strip()
if raw.lower() == "quit":
sys.exit(0)
folder = Path(raw)
if folder.is_dir():
return folder
print("That doesn't look like a valid folder. Try again, or type quit.")
def dangerous_path_reason(folder: Path) -> Optional[str]:
"""Return a reason string if the folder looks unsafe to bulk-delete from."""
resolved = folder.resolve()
if resolved.parent == resolved:
return f"{resolved} is a filesystem root"
try:
if resolved == Path.home().resolve():
return f"{resolved} is your home directory"
except RuntimeError:
pass
if resolved.name.lower() in DANGEROUS_NAMES:
return f"{resolved} looks like a system directory"
return None
def find_matching_files(
folder: Path,
extensions: List[str],
recursive: bool,
exclude_patterns: List[str],
follow_symlinks: bool,
min_age_days: Optional[float],
) -> List[Path]:
matches: Set[Path] = set()
for extension in extensions:
pattern = f"*{extension}"
iterator = folder.rglob(pattern) if recursive else folder.glob(pattern)
matches.update(p for p in iterator if p.is_file() or p.is_symlink())
skipped_symlinks = 0
cutoff = time.time() - min_age_days * SECONDS_PER_DAY if min_age_days else None
results = []
for path in matches:
if path.is_symlink() and not follow_symlinks:
skipped_symlinks += 1
log.debug("Skipping symlink: %s", path)
continue
rel_parts = path.relative_to(folder).parts
if any(
fnmatch.fnmatch(path.name, pat) or any(fnmatch.fnmatch(part, pat) for part in rel_parts)
for pat in exclude_patterns
):
log.debug("Excluded by --exclude: %s", path)
continue
if cutoff is not None and path.stat().st_mtime > cutoff:
log.debug("Too recent for --min-age-days: %s", path)
continue
results.append(path)
if skipped_symlinks:
log.info(
"Skipping %d symlink(s); use --follow-symlinks to include them.",
skipped_symlinks,
)
return sorted(results)
def delete_files(matches: List[Path], permanent: bool) -> tuple[int, int]:
deleted, failed = 0, 0
send2trash = None
if not permanent:
try:
from send2trash import send2trash as send2trash_fn
send2trash = send2trash_fn
except ImportError:
log.error(
"The 'send2trash' package is required to move files to the trash.\n"
"Install it with 'pip install send2trash', or pass --permanent "
"to delete files without using the trash."
)
sys.exit(1)
for path in matches:
try:
if permanent:
path.unlink()
else:
send2trash(str(path))
deleted += 1
log.info("Deleted: %s", path)
except OSError as exc:
failed += 1
log.error("Could not delete %s: %s", path, exc)
except Exception as exc: # send2trash raises platform-specific errors
failed += 1
log.error("Could not move %s to trash: %s", path, exc)
return deleted, failed
def run_once(args: argparse.Namespace, extensions: List[str], folder: Path) -> None:
matches = find_matching_files(
folder,
extensions,
args.recursive,
args.exclude,
args.follow_symlinks,
args.min_age_days,
)
if not matches:
log.info(
"No files matching %s were found in %s.", ", ".join(extensions), folder
)
return
log.info("Found %d file(s) matching %s:", len(matches), ", ".join(extensions))
for path in matches:
log.info(" %s", path)
if args.dry_run:
log.info("Dry run: nothing was deleted.")
return
if not args.yes:
answer = input(f"Delete these {len(matches)} file(s)? y/n: ").strip().lower()
if not answer.startswith("y"):
log.info("Bye!")
return
deleted, failed = delete_files(matches, args.permanent)
destination = "permanently" if args.permanent else "to the trash"
log.info("Done. Deleted %d file(s) %s, %d failure(s).", deleted, destination, failed)
def main(argv: Optional[List[str]] = None) -> None:
args = parse_args(argv)
configure_logging(args.verbose, args.log_file)
folder = Path(args.folder) if args.folder else None
if folder is None or not folder.is_dir():
if args.folder:
log.error("Folder does not exist: %s", args.folder)
folder = prompt_for_folder()
reason = dangerous_path_reason(folder)
if reason and not args.force:
log.error("Refusing to run: %s. Pass --force to override.", reason)
sys.exit(1)
extension_raw = args.extension or input(
"Type the file extension(s) you want to remove, comma-separated (i.e. .txt): "
)
extensions = [
e if e.startswith(".") else f".{e}"
for e in (e.strip() for e in extension_raw.split(","))
if e
]
if not extensions:
log.error("No file extension provided.")
sys.exit(1)
if args.watch is None:
run_once(args, extensions, folder)
return
log.info("Watch mode: running every %.0fs. Press Ctrl+C to stop.", args.watch)
try:
while True:
run_once(args, extensions, folder)
time.sleep(args.watch)
except KeyboardInterrupt:
log.info("Stopped.")
if __name__ == "__main__":
main()