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
| """rm-ish command that sends files and directories to the OS trash."""
from __future__ import annotations
import os import stat import sys from dataclasses import dataclass from pathlib import Path
try: from send2trash import send2trash except ImportError: print( "rmtrash: Python package 'Send2Trash' is not installed.\n" "Install it with: python -m pip install --user Send2Trash", file=sys.stderr, ) sys.exit(4)
APP_NAME = Path(sys.argv[0]).name VERSION = "2.0-send2trash"
@dataclass class Options: force: bool = False interactive: str = "default" prompt_once: bool = False verbose: bool = False paths: list[str] | None = None
def usage(stream=sys.stdout) -> None: print(f"Usage: {APP_NAME} [OPTION]... FILE...", file=stream)
def help_text() -> None: usage() print( """ Send files and directories to the operating system trash.
This intentionally accepts directories without requiring -r, matching the safe-trash behavior preferred for an rm alias.
Options: -f, --force ignore nonexistent files, never prompt -i prompt before every trash operation -I prompt once before trashing many items or directories --interactive[=WHEN] prompt according to WHEN: never, once, or always -r, -R, --recursive accepted for rm compatibility -d, --dir accepted for rm compatibility --one-file-system accepted for rm compatibility --preserve-root refuse to trash / (default) --no-preserve-root accepted, but / is still refused -v, --verbose explain what is being done --help display this help and exit --version output version information and exit """.strip() )
def parse_interactive(value: str | None) -> str: if value in (None, "", "always", "yes"): return "always" if value in ("never", "no", "none"): return "never" if value == "once": return "once" print(f"{APP_NAME}: invalid argument '{value}' for '--interactive'", file=sys.stderr) print("Valid arguments are: never, no, none, once, always, yes", file=sys.stderr) sys.exit(2)
def parse_args(argv: list[str]) -> Options: opts = Options(paths=[]) end_of_options = False
for arg in argv: if end_of_options: opts.paths.append(arg) continue
if arg == "--": end_of_options = True elif arg == "--help": help_text() sys.exit(0) elif arg == "--version": print(f"{APP_NAME} {VERSION}") sys.exit(0) elif arg == "--force": opts.force = True opts.interactive = "never" elif arg.startswith("--interactive="): opts.force = False opts.interactive = parse_interactive(arg.split("=", 1)[1]) elif arg == "--interactive": opts.force = False opts.interactive = "always" elif arg in ("--recursive", "--dir", "--one-file-system", "--preserve-root", "--no-preserve-root"): pass elif arg == "--verbose": opts.verbose = True elif arg.startswith("--"): print(f"{APP_NAME}: unrecognized option '{arg}'", file=sys.stderr) usage(sys.stderr) sys.exit(2) elif arg.startswith("-") and arg != "-": for flag in arg[1:]: if flag == "f": opts.force = True opts.interactive = "never" elif flag == "i": opts.force = False opts.interactive = "always" elif flag == "I": opts.force = False opts.interactive = "once" elif flag in ("r", "R", "d"): pass elif flag == "v": opts.verbose = True else: print(f"{APP_NAME}: invalid option -- '{flag}'", file=sys.stderr) usage(sys.stderr) sys.exit(2) else: opts.paths.append(arg)
if opts.interactive == "once": opts.prompt_once = True opts.interactive = "default"
return opts
def strip_trailing_slashes(path: str) -> str: if path == "/": return path stripped = path.rstrip("/") return stripped or path
def protected_path(raw: str) -> str | None: path = strip_trailing_slashes(raw) if path == "/": return "cannot remove root directory '/'" if Path(path).name in (".", ".."): return f"refusing to remove '.' or '..' directory: skipping '{raw}'" return None
def exists_or_link(path: str) -> bool: return os.path.exists(path) or os.path.islink(path)
def is_directory(path: str) -> bool: try: mode = os.lstat(path).st_mode except OSError: return False if stat.S_ISLNK(mode): return False return stat.S_ISDIR(mode)
def prompt(question: str) -> bool: if not sys.stdin.isatty(): print(f"{APP_NAME}: refusing interactive prompt without a terminal", file=sys.stderr) return False answer = input(f"{question} ") return answer.lower() in ("y", "yes")
def should_prompt_once(paths: list[str]) -> bool: if len(paths) > 3: return True return any(exists_or_link(path) and is_directory(path) for path in paths)
def main(argv: list[str]) -> int: opts = parse_args(argv) paths = opts.paths or []
if not paths: print(f"{APP_NAME}: too few arguments", file=sys.stderr) usage(sys.stderr) return 2
exit_code = 0 candidates: list[tuple[str, str]] = []
for raw in paths: reason = protected_path(raw) if reason is not None: print(f"{APP_NAME}: {reason}", file=sys.stderr) exit_code = 1 continue
path = strip_trailing_slashes(raw) if not exists_or_link(path): if not opts.force: print(f"{APP_NAME}: cannot remove '{raw}': No such file or directory", file=sys.stderr) exit_code = 1 continue
candidates.append((raw, path))
if opts.prompt_once and candidates and should_prompt_once([path for _, path in candidates]): if not prompt(f"{APP_NAME}: trash {len(candidates)} item(s)?"): return exit_code
for raw, path in candidates: if opts.interactive == "always" and not prompt(f"{APP_NAME}: trash '{raw}'?"): continue
try: send2trash(path) except Exception as exc: print(f"{APP_NAME}: cannot trash '{raw}': {exc}", file=sys.stderr) exit_code = 1 continue
if opts.verbose: print(f"{APP_NAME}: trashed '{raw}'")
return exit_code
if __name__ == "__main__": sys.exit(main(sys.argv[1:]))
|