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.
@@ -1,15 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: delayed_rm
3
- Version: 2.12.0
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.10
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.10
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__ = "2.12.0"
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 _eprint(e: str | BaseException) -> None:
67
- """
68
- Print e to stderr
69
- """
70
- e2: str | BaseException = e
71
- if isinstance(e, RMError) and isinstance(e.__cause__, BaseException):
72
- e2 = e.__cause__
73
- err: str = (
74
- "Error"
75
- if isinstance(e2, RMError) or not isinstance(e, BaseException)
76
- else str(type(e2)).split("'")[1].split("delayed_rm.")[-1]
77
- )
78
- print(f"{err}: {e}", file=sys.stderr)
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
- Make base/name and set permissions to 700
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 delayed_rm(paths: list[Path], delay: int, rf: bool) -> bool:
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
- edited = False
162
+ any_edited = False
145
163
  try:
146
164
  for p in paths:
147
- # Select an output directory that an item of name p.name does not exist
148
- outd: Path
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
- outd = next(iter(out_dirs - where[p.name]))
168
+ out_d: Path = next(iter(out_dirs - where[p.name]))
151
169
  else:
152
- outd = _mkdir(base / str(len(out_dirs)))
153
- out_dirs.add(outd)
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
- new: Path = outd / p.name
175
+ _copytree = False
157
176
  try:
158
177
  p.rename(new)
159
178
  edited = True
160
179
  except OSError:
161
- copyf = lambda src, dst: shutil.copy2(src, dst, follow_symlinks=False)
180
+ copy3 = lambda src, dst: shutil.copy2(src, dst, follow_symlinks=False)
162
181
  if p.is_dir() and not p.is_symlink():
163
- shutil.copytree(p, new, copy_function=copyf, symlinks=True)
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
- copyf(p, new)
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(outd)
192
+ where[p.name].add(out_d)
173
193
  except OSError as e:
174
194
  failed.append(p)
175
- _eprint(e)
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
- _eprint("failed to rm:\n " + "\n ".join(failed_plus))
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 " None"
185
- msg: str = (
186
- str(datetime.now())
187
- + "\n "
188
- + "\n".join(
189
- (
190
- ("Interrupted by: SIGINT\n" if ctrlc else "") + f"Delay: {delay}",
191
- f"rf: {rf}",
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
- print(msg)
228
+ _print_stderr(msg)
204
229
  raise
205
230
  # Delay rm and die
206
- if not edited:
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
- _eprint("--log may not be used with other arguments")
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
- _eprint("nothing to remove")
256
+ _print_exc(ValueError("nothing to remove; try --help"))
232
257
  elif r != f:
233
- _eprint("-r and -f must be used together")
258
+ _print_exc(ValueError("-r and -f must be used together"))
234
259
  elif delay < 0:
235
- _eprint("delay may not be negative")
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
- _eprint(e)
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
- print(
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
- f.write(f"Removing: {d}" + "\n\n")
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
- print(f"argv: {sys.argv}", flush=True)
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: 2.12.0
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.10
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.10
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.10",
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.10"
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 = ["py310", "py311", "py312"]
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