diff --git a/send2trash/plat_gio.py b/send2trash/plat_gio.py index 6184d80..9809fe7 100644 --- a/send2trash/plat_gio.py +++ b/send2trash/plat_gio.py @@ -7,13 +7,17 @@ from gi.repository import GObject, Gio from .exceptions import TrashPermissionError -def send2trash(path): - try: - f = Gio.File.new_for_path(path) - f.trash(cancellable=None) - except GObject.GError as e: - if e.code == Gio.IOErrorEnum.NOT_SUPPORTED: - # We get here if we can't create a trash directory on the same - # device. I don't know if other errors can result in NOT_SUPPORTED. - raise TrashPermissionError('') - raise OSError(e.message) + +def send2trash(paths): + if not isinstance(paths, list): + paths = [paths] + for path in paths: + try: + f = Gio.File.new_for_path(path) + f.trash(cancellable=None) + except GObject.GError as e: + if e.code == Gio.IOErrorEnum.NOT_SUPPORTED: + # We get here if we can't create a trash directory on the same + # device. I don't know if other errors can result in NOT_SUPPORTED. + raise TrashPermissionError("") + raise OSError(e.message) diff --git a/send2trash/plat_osx.py b/send2trash/plat_osx.py index 4ac830e..ac6634c 100644 --- a/send2trash/plat_osx.py +++ b/send2trash/plat_osx.py @@ -11,8 +11,8 @@ from ctypes.util import find_library from .compat import binary_type -Foundation = cdll.LoadLibrary(find_library('Foundation')) -CoreServices = cdll.LoadLibrary(find_library('CoreServices')) +Foundation = cdll.LoadLibrary(find_library("Foundation")) +CoreServices = cdll.LoadLibrary(find_library("CoreServices")) GetMacOSStatusCommentString = Foundation.GetMacOSStatusCommentString GetMacOSStatusCommentString.restype = c_char_p @@ -28,21 +28,29 @@ kFSFileOperationSkipSourcePermissionErrors = 0x02 kFSFileOperationDoNotMoveAcrossVolumes = 0x04 kFSFileOperationSkipPreflight = 0x08 + class FSRef(Structure): - _fields_ = [('hidden', c_char * 80)] + _fields_ = [("hidden", c_char * 80)] + def check_op_result(op_result): if op_result: - msg = GetMacOSStatusCommentString(op_result).decode('utf-8') + msg = GetMacOSStatusCommentString(op_result).decode("utf-8") raise OSError(msg) -def send2trash(path): - if not isinstance(path, binary_type): - path = path.encode('utf-8') - fp = FSRef() - opts = kFSPathMakeRefDoNotFollowLeafSymlink - op_result = FSPathMakeRefWithOptions(path, opts, byref(fp), None) - check_op_result(op_result) - opts = kFSFileOperationDefaultOptions - op_result = FSMoveObjectToTrashSync(byref(fp), None, opts) - check_op_result(op_result) + +def send2trash(paths): + if not isinstance(paths, list): + paths = [paths] + paths = [ + path.encode("utf-8") if not isinstance(path, binary_type) else path + for path in paths + ] + for path in paths: + fp = FSRef() + opts = kFSPathMakeRefDoNotFollowLeafSymlink + op_result = FSPathMakeRefWithOptions(path, opts, byref(fp), None) + check_op_result(op_result) + opts = kFSFileOperationDefaultOptions + op_result = FSMoveObjectToTrashSync(byref(fp), None, opts) + check_op_result(op_result) diff --git a/send2trash/plat_other.py b/send2trash/plat_other.py index 624eb99..dd45606 100644 --- a/send2trash/plat_other.py +++ b/send2trash/plat_other.py @@ -22,6 +22,7 @@ import os import os.path as op from datetime import datetime import stat + try: from urllib.parse import quote except ImportError: @@ -32,31 +33,35 @@ from .compat import text_type, environb from .exceptions import TrashPermissionError try: - fsencode = os.fsencode # Python 3 + fsencode = os.fsencode # Python 3 fsdecode = os.fsdecode except AttributeError: - def fsencode(u): # Python 2 + + def fsencode(u): # Python 2 return u.encode(sys.getfilesystemencoding()) + def fsdecode(b): return b.decode(sys.getfilesystemencoding()) + # The Python 3 versions are a bit smarter, handling surrogate escapes, # but these should work in most cases. -FILES_DIR = b'files' -INFO_DIR = b'info' -INFO_SUFFIX = b'.trashinfo' +FILES_DIR = b"files" +INFO_DIR = b"info" +INFO_SUFFIX = b".trashinfo" # Default of ~/.local/share [3] -XDG_DATA_HOME = op.expanduser(environb.get(b'XDG_DATA_HOME', b'~/.local/share')) -HOMETRASH_B = op.join(XDG_DATA_HOME, b'Trash') +XDG_DATA_HOME = op.expanduser(environb.get(b"XDG_DATA_HOME", b"~/.local/share")) +HOMETRASH_B = op.join(XDG_DATA_HOME, b"Trash") HOMETRASH = fsdecode(HOMETRASH_B) uid = os.getuid() -TOPDIR_TRASH = b'.Trash' -TOPDIR_FALLBACK = b'.Trash-' + text_type(uid).encode('ascii') +TOPDIR_TRASH = b".Trash" +TOPDIR_FALLBACK = b".Trash-" + text_type(uid).encode("ascii") + def is_parent(parent, path): - path = op.realpath(path) # In case it's a symlink + path = op.realpath(path) # In case it's a symlink if isinstance(path, text_type): path = fsencode(path) parent = op.realpath(parent) @@ -64,9 +69,11 @@ def is_parent(parent, path): parent = fsencode(parent) return path.startswith(parent) + def format_date(date): return date.strftime("%Y-%m-%dT%H:%M:%S") + def info_for(src, topdir): # ...it MUST not include a ".." directory, and for files not "under" that # directory, absolute pathnames must be used. [2] @@ -75,16 +82,18 @@ def info_for(src, topdir): else: src = op.relpath(src, topdir) - info = "[Trash Info]\n" + info = "[Trash Info]\n" info += "Path=" + quote(src) + "\n" info += "DeletionDate=" + format_date(datetime.now()) + "\n" return info + def check_create(dir): # use 0700 for paths [3] if not op.exists(dir): os.makedirs(dir, 0o700) + def trash_move(src, dst, topdir=None): filename = op.basename(src) filespath = op.join(dst, FILES_DIR) @@ -93,26 +102,30 @@ def trash_move(src, dst, topdir=None): counter = 0 destname = filename - while op.exists(op.join(filespath, destname)) or op.exists(op.join(infopath, destname + INFO_SUFFIX)): + while op.exists(op.join(filespath, destname)) or op.exists( + op.join(infopath, destname + INFO_SUFFIX) + ): counter += 1 - destname = base_name + b' ' + text_type(counter).encode('ascii') + ext + destname = base_name + b" " + text_type(counter).encode("ascii") + ext check_create(filespath) check_create(infopath) os.rename(src, op.join(filespath, destname)) - f = open(op.join(infopath, destname + INFO_SUFFIX), 'w') + f = open(op.join(infopath, destname + INFO_SUFFIX), "w") f.write(info_for(src, topdir)) f.close() + def find_mount_point(path): # Even if something's wrong, "/" is a mount point, so the loop will exit. # Use realpath in case it's a symlink - path = op.realpath(path) # Required to avoid infinite loop + path = op.realpath(path) # Required to avoid infinite loop while not op.ismount(path): path = op.split(path)[0] return path + def find_ext_volume_global_trash(volume_root): # from [2] Trash directories (1) check for a .Trash dir with the right # permissions set. @@ -126,13 +139,14 @@ def find_ext_volume_global_trash(volume_root): if not op.isdir(trash_dir) or op.islink(trash_dir) or not (mode & stat.S_ISVTX): return None - trash_dir = op.join(trash_dir, text_type(uid).encode('ascii')) + trash_dir = op.join(trash_dir, text_type(uid).encode("ascii")) try: check_create(trash_dir) except OSError: return None return trash_dir + def find_ext_volume_fallback_trash(volume_root): # from [2] Trash directories (1) create a .Trash-$uid dir. trash_dir = op.join(volume_root, TOPDIR_FALLBACK) @@ -145,48 +159,54 @@ def find_ext_volume_fallback_trash(volume_root): raise return trash_dir + def find_ext_volume_trash(volume_root): trash_dir = find_ext_volume_global_trash(volume_root) if trash_dir is None: trash_dir = find_ext_volume_fallback_trash(volume_root) return trash_dir + # Pull this out so it's easy to stub (to avoid stubbing lstat itself) def get_dev(path): return os.lstat(path).st_dev -def send2trash(path): - if isinstance(path, text_type): - path_b = fsencode(path) - elif isinstance(path, bytes): - path_b = path - elif hasattr(path, '__fspath__'): - # Python 3.6 PathLike protocol - return send2trash(path.__fspath__()) - else: - raise TypeError('str, bytes or PathLike expected, not %r' % type(path)) - if not op.exists(path_b): - raise OSError("File not found: %s" % path) - # ...should check whether the user has the necessary permissions to delete - # it, before starting the trashing operation itself. [2] - if not os.access(path_b, os.W_OK): - raise OSError("Permission denied: %s" % path) - # if the file to be trashed is on the same device as HOMETRASH we - # want to move it there. - path_dev = get_dev(path_b) +def send2trash(paths): + if not isinstance(paths, list): + paths = [paths] + for path in paths: + if isinstance(path, text_type): + path_b = fsencode(path) + elif isinstance(path, bytes): + path_b = path + elif hasattr(path, "__fspath__"): + # Python 3.6 PathLike protocol + return send2trash(path.__fspath__()) + else: + raise TypeError("str, bytes or PathLike expected, not %r" % type(path)) - # If XDG_DATA_HOME or HOMETRASH do not yet exist we need to stat the - # home directory, and these paths will be created further on if needed. - trash_dev = get_dev(op.expanduser(b'~')) + if not op.exists(path_b): + raise OSError("File not found: %s" % path) + # ...should check whether the user has the necessary permissions to delete + # it, before starting the trashing operation itself. [2] + if not os.access(path_b, os.W_OK): + raise OSError("Permission denied: %s" % path) + # if the file to be trashed is on the same device as HOMETRASH we + # want to move it there. + path_dev = get_dev(path_b) - if path_dev == trash_dev: - topdir = XDG_DATA_HOME - dest_trash = HOMETRASH_B - else: - topdir = find_mount_point(path_b) - trash_dev = get_dev(topdir) - if trash_dev != path_dev: - raise OSError("Couldn't find mount point for %s" % path) - dest_trash = find_ext_volume_trash(topdir) - trash_move(path_b, dest_trash, topdir) + # If XDG_DATA_HOME or HOMETRASH do not yet exist we need to stat the + # home directory, and these paths will be created further on if needed. + trash_dev = get_dev(op.expanduser(b"~")) + + if path_dev == trash_dev: + topdir = XDG_DATA_HOME + dest_trash = HOMETRASH_B + else: + topdir = find_mount_point(path_b) + trash_dev = get_dev(topdir) + if trash_dev != path_dev: + raise OSError("Couldn't find mount point for %s" % path) + dest_trash = find_ext_volume_trash(topdir) + trash_move(path_b, dest_trash, topdir)