delayed-rm 2.12.0__tar.gz → 3.0.0__tar.gz
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.
- {delayed_rm-2.12.0 → delayed_rm-3.0.0}/PKG-INFO +3 -3
- {delayed_rm-2.12.0 → delayed_rm-3.0.0}/delayed_rm/delayed_rm.py +106 -76
- {delayed_rm-2.12.0 → delayed_rm-3.0.0}/delayed_rm.egg-info/PKG-INFO +3 -3
- {delayed_rm-2.12.0 → delayed_rm-3.0.0}/pyproject.toml +3 -3
- {delayed_rm-2.12.0 → delayed_rm-3.0.0}/LICENSE +0 -0
- {delayed_rm-2.12.0 → delayed_rm-3.0.0}/README.md +0 -0
- {delayed_rm-2.12.0 → delayed_rm-3.0.0}/delayed_rm/__init__.py +0 -0
- {delayed_rm-2.12.0 → delayed_rm-3.0.0}/delayed_rm/py.typed +0 -0
- {delayed_rm-2.12.0 → delayed_rm-3.0.0}/delayed_rm.egg-info/SOURCES.txt +0 -0
- {delayed_rm-2.12.0 → delayed_rm-3.0.0}/delayed_rm.egg-info/dependency_links.txt +0 -0
- {delayed_rm-2.12.0 → delayed_rm-3.0.0}/delayed_rm.egg-info/entry_points.txt +0 -0
- {delayed_rm-2.12.0 → delayed_rm-3.0.0}/delayed_rm.egg-info/top_level.txt +0 -0
- {delayed_rm-2.12.0 → delayed_rm-3.0.0}/setup.cfg +0 -0
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: delayed_rm
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.0.0
|
|
4
4
|
Summary: Ever wish you had a few minutes to undo an rm? Now you do!
|
|
5
5
|
License: GPLv3
|
|
6
6
|
Project-URL: Homepage, https://github.com/zwimer/delayed_rm
|
|
7
7
|
Keywords: rm
|
|
8
8
|
Classifier: Programming Language :: Python :: 3
|
|
9
|
-
Classifier: Programming Language :: Python :: 3.
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
10
10
|
Classifier: Development Status :: 5 - Production/Stable
|
|
11
11
|
Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
|
|
12
|
-
Requires-Python: >=3.
|
|
12
|
+
Requires-Python: >=3.12
|
|
13
13
|
Description-Content-Type: text/markdown
|
|
14
14
|
License-File: LICENSE
|
|
15
15
|
Dynamic: license-file
|
|
@@ -12,22 +12,13 @@ import math
|
|
|
12
12
|
import time
|
|
13
13
|
import sys
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
__version__ = "
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
#
|
|
20
|
-
# Config
|
|
21
|
-
#
|
|
22
|
-
|
|
23
|
-
|
|
15
|
+
# Constants
|
|
16
|
+
__version__ = "3.0.0"
|
|
17
|
+
_UNSAFE_FLAG = "unsafe-rmtree"
|
|
24
18
|
log_f: Path = Path.home().resolve() / ".delayed_rm.log"
|
|
25
19
|
tmp_d: Path = Path(gettempdir()).resolve() / ".delayed_rm"
|
|
26
20
|
|
|
27
|
-
|
|
28
|
-
#
|
|
29
21
|
# Classes
|
|
30
|
-
#
|
|
31
22
|
|
|
32
23
|
|
|
33
24
|
class RMError(Exception):
|
|
@@ -53,37 +44,39 @@ class _Secret:
|
|
|
53
44
|
|
|
54
45
|
|
|
55
46
|
def _size(p: Path) -> str:
|
|
56
|
-
"""
|
|
57
|
-
Get file size as a human readable string
|
|
58
|
-
This does follow symlinks
|
|
59
|
-
"""
|
|
47
|
+
"""Get file size as a human-readable string; follows symlinks"""
|
|
60
48
|
s = p.stat().st_size
|
|
61
49
|
lg = int(math.log(s, 1000))
|
|
62
50
|
si = " KMGT"[lg].replace(" ", "")
|
|
63
51
|
return f"{round(s/(1000**lg))} {si}B"
|
|
64
52
|
|
|
65
53
|
|
|
66
|
-
def
|
|
67
|
-
"""
|
|
68
|
-
|
|
69
|
-
""
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
54
|
+
def _efmt(e: Exception) -> str:
|
|
55
|
+
"""Format e as a printable error string"""
|
|
56
|
+
cause: BaseException = e.__cause__ if isinstance(e, RMError) and e.__cause__ is not None else e
|
|
57
|
+
name = "Error" if isinstance(cause, RMError) else cause.__class__.__name__
|
|
58
|
+
if not isinstance(e, shutil.Error):
|
|
59
|
+
return f"{name}: {str(e)}"
|
|
60
|
+
try: # This is almost certainly from copytree it's the only thing that can raise this?
|
|
61
|
+
body = "\n".join(str(i[2]) for i in e.args[0]) # str is just in case
|
|
62
|
+
except IndexError: # Just in case, but shouldn't be possible
|
|
63
|
+
body = str(e)
|
|
64
|
+
return f"{name}: {body}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _print_stderr(x) -> None:
|
|
68
|
+
"""Print x to stderr"""
|
|
69
|
+
print(x, file=sys.stderr, flush=True)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _print_exc(e: Exception) -> None:
|
|
73
|
+
"""Print an error to stderr"""
|
|
74
|
+
_print_stderr(_efmt(e))
|
|
79
75
|
|
|
80
76
|
|
|
81
77
|
def _mkdir(ret: Path) -> Path:
|
|
82
|
-
"""
|
|
83
|
-
|
|
84
|
-
"""
|
|
85
|
-
ret.mkdir()
|
|
86
|
-
ret.chmod(0o700)
|
|
78
|
+
"""Make base/name and set permissions to 700"""
|
|
79
|
+
ret.mkdir(mode=0o700)
|
|
87
80
|
return ret
|
|
88
81
|
|
|
89
82
|
|
|
@@ -117,14 +110,39 @@ def _prep(paths: list[Path], rf: bool) -> list[Path]:
|
|
|
117
110
|
return paths
|
|
118
111
|
|
|
119
112
|
|
|
120
|
-
def
|
|
113
|
+
def _rmtree(d: Path) -> str:
|
|
114
|
+
"""rmtree that ignores errors and returns them as a string"""
|
|
115
|
+
err = []
|
|
116
|
+
shutil.rmtree(d, onexc=lambda *x: err.append(x[2]))
|
|
117
|
+
return "\n".join(_efmt(i) for i in err)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def safety_check(unsafe_ok: bool) -> None:
|
|
121
|
+
"""Check if this program is safe to run; raise if not or warn if unsafe_ok is True"""
|
|
122
|
+
if getattr(shutil.rmtree, "avoids_symlink_attacks", False):
|
|
123
|
+
return
|
|
124
|
+
if not unsafe_ok:
|
|
125
|
+
raise RuntimeError(
|
|
126
|
+
"This program cannot safely be run.\n"
|
|
127
|
+
" This platform does not support fd-based dir access functions\n"
|
|
128
|
+
" This makes rmtree vulnerable to TOCTOU attacks.\n"
|
|
129
|
+
f" Pass --{_UNSAFE_FLAG} to bypass this"
|
|
130
|
+
)
|
|
131
|
+
_print_stderr("WARNING: Bypassing the rmtree safety check is STRONGLY discouraged")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def delayed_rm(paths: list[Path], delay: int, rf: bool, unsafe: bool = False) -> bool:
|
|
121
135
|
"""
|
|
122
136
|
Move paths to a temporary directory, delete them after delay seconds
|
|
123
137
|
Log's this action to the log
|
|
124
138
|
If rf, acts like rm -rf
|
|
125
139
|
May raise an RMError if something goes wrong
|
|
140
|
+
If unsafe is False and rmtree cannot safely be used, an error will be raised
|
|
126
141
|
:returns: True on success, else False
|
|
127
142
|
"""
|
|
143
|
+
if not paths:
|
|
144
|
+
return True
|
|
145
|
+
safety_check(unsafe)
|
|
128
146
|
if not tmp_d.parent.exists():
|
|
129
147
|
raise RuntimeError("Temp dir enclosing directory does not exist")
|
|
130
148
|
if not log_f.parent.exists():
|
|
@@ -141,69 +159,76 @@ def delayed_rm(paths: list[Path], delay: int, rf: bool) -> bool:
|
|
|
141
159
|
full_where: dict[Path, Path] = {}
|
|
142
160
|
# Delete files
|
|
143
161
|
ctrlc = False
|
|
144
|
-
|
|
162
|
+
any_edited = False
|
|
145
163
|
try:
|
|
146
164
|
for p in paths:
|
|
147
|
-
|
|
148
|
-
|
|
165
|
+
edited = False
|
|
166
|
+
# Select the first output directory that doesn't have something named p.name in it
|
|
149
167
|
if len(out_dirs) != len(where[p.name]):
|
|
150
|
-
|
|
168
|
+
out_d: Path = next(iter(out_dirs - where[p.name]))
|
|
151
169
|
else:
|
|
152
|
-
|
|
153
|
-
out_dirs.add(
|
|
170
|
+
out_d = _mkdir(base / str(len(out_dirs)))
|
|
171
|
+
out_dirs.add(out_d)
|
|
154
172
|
# Move file into the temp directory
|
|
173
|
+
new: Path = out_d / p.name
|
|
155
174
|
try:
|
|
156
|
-
|
|
175
|
+
_copytree = False
|
|
157
176
|
try:
|
|
158
177
|
p.rename(new)
|
|
159
178
|
edited = True
|
|
160
179
|
except OSError:
|
|
161
|
-
|
|
180
|
+
copy3 = lambda src, dst: shutil.copy2(src, dst, follow_symlinks=False)
|
|
162
181
|
if p.is_dir() and not p.is_symlink():
|
|
163
|
-
|
|
182
|
+
_copytree = True
|
|
183
|
+
shutil.copytree(p, new, copy_function=copy3, symlinks=True)
|
|
164
184
|
edited = True
|
|
165
185
|
shutil.rmtree(p)
|
|
166
186
|
else:
|
|
167
|
-
|
|
187
|
+
copy3(p, new)
|
|
168
188
|
edited = True
|
|
169
189
|
p.unlink()
|
|
170
190
|
success.append(p)
|
|
171
191
|
full_where[p] = new
|
|
172
|
-
where[p.name].add(
|
|
192
|
+
where[p.name].add(out_d)
|
|
173
193
|
except OSError as e:
|
|
174
194
|
failed.append(p)
|
|
175
|
-
|
|
195
|
+
_print_exc(e)
|
|
196
|
+
if edited:
|
|
197
|
+
_print_stderr(f"WARNING: Contents of {p} may have been PARTIALLY delayed_rm'd")
|
|
198
|
+
continue
|
|
199
|
+
if _copytree:
|
|
200
|
+
if output := _rmtree(new):
|
|
201
|
+
_print_stderr(output)
|
|
202
|
+
continue
|
|
203
|
+
new.unlink()
|
|
204
|
+
finally:
|
|
205
|
+
any_edited |= edited
|
|
176
206
|
except KeyboardInterrupt:
|
|
177
207
|
ctrlc = True
|
|
178
208
|
# Inform user of failures
|
|
179
209
|
failed_plus: list[str] = [str(i) for i in failed]
|
|
180
210
|
if len(failed) > 0 and not ctrlc:
|
|
181
|
-
|
|
211
|
+
_print_exc(OSError("failed to rm:\n " + "\n ".join(failed_plus)))
|
|
182
212
|
# Log result
|
|
183
213
|
success_plus: list[str] = [f"{i} ---> {full_where[i]}" for i in success]
|
|
184
|
-
fmt: Callable[[list[str]], str] = lambda l: ("\n " + "\n ".join(l)) if l else "
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
f"Storage Directory: {base}",
|
|
193
|
-
f"Succeeded:{fmt(success_plus)}",
|
|
194
|
-
f"Failed:{fmt(failed_plus)}",
|
|
195
|
-
)
|
|
196
|
-
).replace("\n", "\n ")
|
|
197
|
-
+ "\n\n"
|
|
214
|
+
fmt: Callable[[list[str]], str] = lambda l: ("\n " + "\n ".join(l)) if l else " False"
|
|
215
|
+
lines = (
|
|
216
|
+
f"{datetime.now()}{'\nInterrupted by: SIGINT' if ctrlc else ''}",
|
|
217
|
+
f"Delay: {delay}",
|
|
218
|
+
f"rf: {rf}",
|
|
219
|
+
f"Storage Directory: {base}",
|
|
220
|
+
f"Succeeded:{fmt(success_plus)}",
|
|
221
|
+
f"Failed:{fmt(failed_plus)}",
|
|
198
222
|
)
|
|
223
|
+
msg = "\n".join(lines).replace("\n", "\n ") + "\n\n"
|
|
199
224
|
try:
|
|
200
225
|
with log_f.open("a") as f:
|
|
201
226
|
f.write(msg)
|
|
202
227
|
except OSError:
|
|
203
|
-
|
|
228
|
+
_print_stderr(msg)
|
|
204
229
|
raise
|
|
205
230
|
# Delay rm and die
|
|
206
|
-
if not
|
|
231
|
+
if not any_edited:
|
|
207
232
|
shutil.rmtree(base)
|
|
208
233
|
else:
|
|
209
234
|
subprocess.Popen( # pylint: disable=consider-using-with # nosec B603
|
|
@@ -215,7 +240,7 @@ def delayed_rm(paths: list[Path], delay: int, rf: bool) -> bool:
|
|
|
215
240
|
return not failed and not ctrlc
|
|
216
241
|
|
|
217
242
|
|
|
218
|
-
def delayed_rm_raw(delay: int, log: bool, r: bool, f: bool, paths: list[Path]) -> bool:
|
|
243
|
+
def delayed_rm_raw(delay: int, log: bool, r: bool, f: bool, paths: list[Path], unsafe: bool = False) -> bool:
|
|
219
244
|
"""
|
|
220
245
|
A delayed_rm wrapper that handles CLI arguments
|
|
221
246
|
:returns: True on success, else False
|
|
@@ -223,20 +248,20 @@ def delayed_rm_raw(delay: int, log: bool, r: bool, f: bool, paths: list[Path]) -
|
|
|
223
248
|
try:
|
|
224
249
|
if log:
|
|
225
250
|
if r or f or paths:
|
|
226
|
-
|
|
251
|
+
_print_exc(ValueError("--log may not be used with other arguments"))
|
|
227
252
|
return False
|
|
228
253
|
print(f"{log_f.read_text()}Log file ({_size(log_f)}): {log_f}" if log_f.exists() else "Log is empty")
|
|
229
254
|
return True
|
|
230
255
|
if not paths:
|
|
231
|
-
|
|
256
|
+
_print_exc(ValueError("nothing to remove; try --help"))
|
|
232
257
|
elif r != f:
|
|
233
|
-
|
|
258
|
+
_print_exc(ValueError("-r and -f must be used together"))
|
|
234
259
|
elif delay < 0:
|
|
235
|
-
|
|
260
|
+
_print_exc(ValueError("delay may not be negative"))
|
|
236
261
|
else:
|
|
237
|
-
return delayed_rm(paths=paths, delay=delay, rf=r)
|
|
262
|
+
return delayed_rm(paths=paths, delay=delay, rf=r, unsafe=unsafe)
|
|
238
263
|
except RMError as e:
|
|
239
|
-
|
|
264
|
+
_print_exc(e)
|
|
240
265
|
return False
|
|
241
266
|
return True
|
|
242
267
|
|
|
@@ -254,6 +279,7 @@ def cli() -> None:
|
|
|
254
279
|
parser.add_argument("-r", action="store_true", help="rm -r; must use -f with this")
|
|
255
280
|
parser.add_argument("-f", action="store_true", help="rm -f; must use -r with this")
|
|
256
281
|
parser.add_argument("paths", type=Path, nargs="*", help="The items to delete")
|
|
282
|
+
parser.add_argument(f"--{_UNSAFE_FLAG}", dest="unsafe", action="store_true", help=argparse.SUPPRESS)
|
|
257
283
|
sys.exit(not delayed_rm_raw(**vars(parser.parse_args())))
|
|
258
284
|
|
|
259
285
|
|
|
@@ -273,21 +299,25 @@ def _secret_cli() -> None:
|
|
|
273
299
|
cli()
|
|
274
300
|
try:
|
|
275
301
|
if len(sys.argv) != 4 or sys.argv[1] != _Secret.value or environ.get(_Secret.key, None) != _Secret.value:
|
|
276
|
-
|
|
302
|
+
_print_stderr(
|
|
277
303
|
"This script should be run by invoking the cli() function.\n"
|
|
278
304
|
"Pass --force-cli as the first argument to bypass this restriction.",
|
|
279
|
-
file=sys.stderr,
|
|
280
305
|
)
|
|
281
306
|
sys.exit(1)
|
|
282
307
|
d = Path(sys.argv[3]).resolve()
|
|
283
308
|
time.sleep(int(sys.argv[2]))
|
|
284
|
-
shutil.rmtree(d)
|
|
285
309
|
with log_f.open("a") as f:
|
|
286
|
-
|
|
310
|
+
if not d.exists(follow_symlinks=False):
|
|
311
|
+
f.write(f"Directory {d} does not exist, nothing to do\n\n")
|
|
312
|
+
return
|
|
313
|
+
f.write(f"Removing: {d}")
|
|
314
|
+
if errs := _rmtree(d):
|
|
315
|
+
f.write(errs)
|
|
316
|
+
f.write("\n\n")
|
|
287
317
|
except Exception:
|
|
288
318
|
sys.stderr = log_f.open("a")
|
|
289
319
|
sys.stdout = sys.stderr
|
|
290
|
-
|
|
320
|
+
_print_stderr(f"argv: {sys.argv}")
|
|
291
321
|
raise
|
|
292
322
|
|
|
293
323
|
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: delayed_rm
|
|
3
|
-
Version:
|
|
3
|
+
Version: 3.0.0
|
|
4
4
|
Summary: Ever wish you had a few minutes to undo an rm? Now you do!
|
|
5
5
|
License: GPLv3
|
|
6
6
|
Project-URL: Homepage, https://github.com/zwimer/delayed_rm
|
|
7
7
|
Keywords: rm
|
|
8
8
|
Classifier: Programming Language :: Python :: 3
|
|
9
|
-
Classifier: Programming Language :: Python :: 3.
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
10
10
|
Classifier: Development Status :: 5 - Production/Stable
|
|
11
11
|
Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
|
|
12
|
-
Requires-Python: >=3.
|
|
12
|
+
Requires-Python: >=3.12
|
|
13
13
|
Description-Content-Type: text/markdown
|
|
14
14
|
License-File: LICENSE
|
|
15
15
|
Dynamic: license-file
|
|
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
|
|
|
6
6
|
name = "delayed_rm"
|
|
7
7
|
classifiers = [
|
|
8
8
|
"Programming Language :: Python :: 3",
|
|
9
|
-
"Programming Language :: Python :: 3.
|
|
9
|
+
"Programming Language :: Python :: 3.12",
|
|
10
10
|
"Development Status :: 5 - Production/Stable",
|
|
11
11
|
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
|
12
12
|
]
|
|
@@ -14,7 +14,7 @@ keywords = ["rm"]
|
|
|
14
14
|
license = {text = "GPLv3"}
|
|
15
15
|
description = "Ever wish you had a few minutes to undo an rm? Now you do!"
|
|
16
16
|
urls = {Homepage = "https://github.com/zwimer/delayed_rm"}
|
|
17
|
-
requires-python = ">= 3.
|
|
17
|
+
requires-python = ">= 3.12"
|
|
18
18
|
dynamic = ["version"]
|
|
19
19
|
|
|
20
20
|
[project.readme]
|
|
@@ -41,7 +41,7 @@ version = {attr = "delayed_rm.__version__"}
|
|
|
41
41
|
|
|
42
42
|
[tool.black]
|
|
43
43
|
line-length = 120
|
|
44
|
-
target-version = ["
|
|
44
|
+
target-version = ["py312", "py313"]
|
|
45
45
|
|
|
46
46
|
[tool.ruff]
|
|
47
47
|
ignore=["E731","E741"]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|