diff --git a/CHANGES.md b/CHANGES.md index d8ca507b..427c8f29 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -4,6 +4,7 @@ * Add Filelock 3.9.0 (ce3e891) * Remove Lockfile no longer used by Cachecontrol * Update Msgpack 1.0.0 (fa7d744) to 1.0.4 (b5acfd5) +* Update Send2Trash 1.5.0 (66afce7) to 1.8.1b0 (0ef9b32) * Update SimpleJSON 3.16.1 (ce75e60) to 3.18.1 (c891b95) * Update tmdbsimple 2.6.6 (679e343) to 2.9.1 (9da400a) * Update torrent_parser 0.3.0 (2a4eecb) to 0.4.0 (23b9e11) diff --git a/lib/send2trash/__init__.py b/lib/send2trash/__init__.py index 3a8c884a..d09faa3a 100644 --- a/lib/send2trash/__init__.py +++ b/lib/send2trash/__init__.py @@ -1,21 +1,21 @@ # Copyright 2013 Hardcoded Software (http://www.hardcoded.net) -# This software is licensed under the "BSD" License as described in the "LICENSE" file, -# which should be included with this package. The terms are also available at +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at # http://www.hardcoded.net/licenses/bsd_license import sys -from .exceptions import TrashPermissionError +from send2trash.exceptions import TrashPermissionError # noqa: F401 -if sys.platform == 'darwin': - from .plat_osx import send2trash -elif sys.platform == 'win32': - from .plat_win import send2trash +if sys.platform == "darwin": + from send2trash.mac import send2trash +elif sys.platform == "win32": + from send2trash.win import send2trash else: try: # If we can use gio, let's use it - from .plat_gio import send2trash + from send2trash.plat_gio import send2trash except ImportError: # Oh well, let's fallback to our own Freedesktop trash implementation - from .plat_other import send2trash + from send2trash.plat_other import send2trash # noqa: F401 diff --git a/lib/send2trash/__main__.py b/lib/send2trash/__main__.py new file mode 100644 index 00000000..a733e82b --- /dev/null +++ b/lib/send2trash/__main__.py @@ -0,0 +1,33 @@ +# encoding: utf-8 +# Copyright 2017 Virgil Dupras + +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +from __future__ import print_function + +import sys + +from argparse import ArgumentParser +from send2trash import send2trash + + +def main(args=None): + parser = ArgumentParser(description="Tool to send files to trash") + parser.add_argument("files", nargs="+") + parser.add_argument("-v", "--verbose", action="store_true", help="Print deleted files") + args = parser.parse_args(args) + + for filename in args.files: + try: + send2trash(filename) + if args.verbose: + print("Trashed «" + filename + "»") + except OSError as e: + print(str(e), file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/lib/send2trash/compat.py b/lib/send2trash/compat.py index 8092d0a7..9e9b5fb2 100644 --- a/lib/send2trash/compat.py +++ b/lib/send2trash/compat.py @@ -15,6 +15,6 @@ if PY3: # environb will be unset under Windows, but then again we're not supposed to use it. environb = os.environb else: - text_type = unicode + text_type = unicode # noqa: F821 binary_type = str environb = os.environ diff --git a/lib/send2trash/exceptions.py b/lib/send2trash/exceptions.py index 4132a6d4..9e827663 100644 --- a/lib/send2trash/exceptions.py +++ b/lib/send2trash/exceptions.py @@ -1,11 +1,12 @@ import errno -from .compat import PY3 +from send2trash.compat import PY3 if PY3: - _permission_error = PermissionError + _permission_error = PermissionError # noqa: F821 else: _permission_error = OSError + class TrashPermissionError(_permission_error): """A permission error specific to a trash directory. @@ -20,6 +21,6 @@ class TrashPermissionError(_permission_error): data between partitions, devices, or network drives, so we don't do it as a fallback. """ + def __init__(self, filename): - _permission_error.__init__(self, errno.EACCES, "Permission denied", - filename) + _permission_error.__init__(self, errno.EACCES, "Permission denied", filename) diff --git a/lib/send2trash/mac/__init__.py b/lib/send2trash/mac/__init__.py new file mode 100644 index 00000000..beff6118 --- /dev/null +++ b/lib/send2trash/mac/__init__.py @@ -0,0 +1,20 @@ +# Copyright 2017 Virgil Dupras + +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +from platform import mac_ver +from sys import version_info + +# NOTE: version of pyobjc only supports python >= 3.6 and 10.9+ +macos_ver = tuple(int(part) for part in mac_ver()[0].split(".")) +if version_info >= (3, 6) and macos_ver >= (10, 9): + try: + from send2trash.mac.modern import send2trash + except ImportError: + # Try to fall back to ctypes version, although likely problematic still + from send2trash.mac.legacy import send2trash +else: + # Just use the old version otherwise + from send2trash.mac.legacy import send2trash # noqa: F401 diff --git a/lib/send2trash/plat_osx.py b/lib/send2trash/mac/legacy.py similarity index 54% rename from lib/send2trash/plat_osx.py rename to lib/send2trash/mac/legacy.py index 4ac830e8..82f43d96 100644 --- a/lib/send2trash/plat_osx.py +++ b/lib/send2trash/mac/legacy.py @@ -9,10 +9,11 @@ from __future__ import unicode_literals from ctypes import cdll, byref, Structure, c_char, c_char_p from ctypes.util import find_library -from .compat import binary_type +from send2trash.compat import binary_type +from send2trash.util import preprocess_paths -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 +29,25 @@ 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): + paths = preprocess_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/lib/send2trash/mac/modern.py b/lib/send2trash/mac/modern.py new file mode 100644 index 00000000..6098d5a5 --- /dev/null +++ b/lib/send2trash/mac/modern.py @@ -0,0 +1,26 @@ +# Copyright 2017 Virgil Dupras + +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +from Foundation import NSFileManager, NSURL +from send2trash.compat import text_type +from send2trash.util import preprocess_paths + + +def check_op_result(op_result): + # First value will be false on failure + if not op_result[0]: + # Error is in third value, localized failure reason matchs ctypes version + raise OSError(op_result[2].localizedFailureReason()) + + +def send2trash(paths): + paths = preprocess_paths(paths) + paths = [path.decode("utf-8") if not isinstance(path, text_type) else path for path in paths] + for path in paths: + file_url = NSURL.fileURLWithPath_(path) + fm = NSFileManager.defaultManager() + op_result = fm.trashItemAtURL_resultingItemURL_error_(file_url, None, None) + check_op_result(op_result) diff --git a/lib/send2trash/plat_gio.py b/lib/send2trash/plat_gio.py index 6184d805..258e4ef9 100644 --- a/lib/send2trash/plat_gio.py +++ b/lib/send2trash/plat_gio.py @@ -5,15 +5,19 @@ # http://www.hardcoded.net/licenses/bsd_license from gi.repository import GObject, Gio -from .exceptions import TrashPermissionError +from send2trash.exceptions import TrashPermissionError +from send2trash.util import preprocess_paths -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): + paths = preprocess_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/lib/send2trash/plat_other.py b/lib/send2trash/plat_other.py index 624eb993..517e2a05 100644 --- a/lib/send2trash/plat_other.py +++ b/lib/send2trash/plat_other.py @@ -19,44 +19,51 @@ from __future__ import unicode_literals import errno import sys import os +import shutil import os.path as op from datetime import datetime import stat + try: from urllib.parse import quote except ImportError: # Python 2 from urllib import quote -from .compat import text_type, environb -from .exceptions import TrashPermissionError +from send2trash.compat import text_type, environb +from send2trash.util import preprocess_paths +from send2trash.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 +71,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,17 +84,19 @@ 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): + +def trash_move(src, dst, topdir=None, cross_dev=False): filename = op.basename(src) filespath = op.join(dst, FILES_DIR) infopath = op.join(dst, INFO_DIR) @@ -95,24 +106,29 @@ def trash_move(src, dst, topdir=None): destname = filename 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.write(info_for(src, topdir)) - f.close() + with open(op.join(infopath, destname + INFO_SUFFIX), "w") as f: + f.write(info_for(src, topdir)) + destpath = op.join(filespath, destname) + if cross_dev: + shutil.move(src, destpath) + else: + os.rename(src, destpath) + 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 - while not op.ismount(path): + path = op.realpath(path) # Required to avoid infinite loop + while not op.ismount(path): # Note ismount() does not always detect mounts 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 +142,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 +162,57 @@ 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): + paths = preprocess_paths(paths) + for path in paths: + if isinstance(path, text_type): + path_b = fsencode(path) + elif isinstance(path, bytes): + path_b = path + 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(errno.ENOENT, "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(errno.EACCES, "Permission denied: %s" % path) - 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) + path_dev = get_dev(path_b) + # 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 the file to be trashed is on the same device as HOMETRASH we + # want to move it there. + 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) + try: + trash_move(path_b, dest_trash, topdir) + except OSError as error: + # Cross link errors default back to HOMETRASH + if error.errno == errno.EXDEV: + trash_move(path_b, HOMETRASH_B, XDG_DATA_HOME, cross_dev=True) + else: + raise diff --git a/lib/send2trash/plat_win.py b/lib/send2trash/plat_win.py deleted file mode 100644 index 760c48e9..00000000 --- a/lib/send2trash/plat_win.py +++ /dev/null @@ -1,103 +0,0 @@ -# Copyright 2017 Virgil Dupras - -# This software is licensed under the "BSD" License as described in the "LICENSE" file, -# which should be included with this package. The terms are also available at -# http://www.hardcoded.net/licenses/bsd_license - -from __future__ import unicode_literals - -from ctypes import (windll, Structure, byref, c_uint, - create_unicode_buffer, addressof, - GetLastError, FormatError) -from ctypes.wintypes import HWND, UINT, LPCWSTR, BOOL -import os.path as op - -from .compat import text_type - -kernel32 = windll.kernel32 -GetShortPathNameW = kernel32.GetShortPathNameW - -shell32 = windll.shell32 -SHFileOperationW = shell32.SHFileOperationW - - -class SHFILEOPSTRUCTW(Structure): - _fields_ = [ - ("hwnd", HWND), - ("wFunc", UINT), - ("pFrom", LPCWSTR), - ("pTo", LPCWSTR), - ("fFlags", c_uint), - ("fAnyOperationsAborted", BOOL), - ("hNameMappings", c_uint), - ("lpszProgressTitle", LPCWSTR), - ] - - -FO_MOVE = 1 -FO_COPY = 2 -FO_DELETE = 3 -FO_RENAME = 4 - -FOF_MULTIDESTFILES = 1 -FOF_SILENT = 4 -FOF_NOCONFIRMATION = 16 -FOF_ALLOWUNDO = 64 -FOF_NOERRORUI = 1024 - - -def get_short_path_name(long_name): - if not long_name.startswith('\\\\?\\'): - long_name = '\\\\?\\' + long_name - buf_size = GetShortPathNameW(long_name, None, 0) - # FIX: https://github.com/hsoft/send2trash/issues/31 - # If buffer size is zero, an error has occurred. - if not buf_size: - err_no = GetLastError() - raise WindowsError(err_no, FormatError(err_no), long_name[4:]) - output = create_unicode_buffer(buf_size) - GetShortPathNameW(long_name, output, buf_size) - return output - - -def send2trash(path): - if not isinstance(path, text_type): - path = text_type(path, 'mbcs') - if not op.isabs(path): - path = op.abspath(path) - - try: - s2t(path) - except (BaseException, Exception): - short_path = get_short_path_name(path) - try: - s2t(short_path) - except (BaseException, Exception): - s2t(short_path.value[4:]) # Remove '\\?\' for SHFileOperationW - - -def s2t(path): - fileop = SHFILEOPSTRUCTW() - fileop.hwnd = 0 - fileop.wFunc = FO_DELETE - # FIX: https://github.com/hsoft/send2trash/issues/17 - # Starting in python 3.6.3 it is no longer possible to use: - # LPCWSTR(path + '\0') directly as embedded null characters are no longer - # allowed in strings - # Workaround - # - create buffer of c_wchar[] (LPCWSTR is based on this type) - # - buffer is two c_wchar characters longer (double null terminator) - # - cast the address of the buffer to a LPCWSTR - # NOTE: based on how python allocates memory for these types they should - # always be zero, if this is ever not true we can go back to explicitly - # setting the last two characters to null using buffer[index] = '\0'. - buf = create_unicode_buffer(path, len(path)+2) - fileop.pFrom = LPCWSTR(addressof(buf)) - fileop.pTo = None - fileop.fFlags = FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_NOERRORUI | FOF_SILENT - fileop.fAnyOperationsAborted = 0 - fileop.hNameMappings = 0 - fileop.lpszProgressTitle = None - result = SHFileOperationW(byref(fileop)) - if result: - raise WindowsError(result, FormatError(result), path) diff --git a/lib/send2trash/util.py b/lib/send2trash/util.py new file mode 100644 index 00000000..2c73d443 --- /dev/null +++ b/lib/send2trash/util.py @@ -0,0 +1,14 @@ +# encoding: utf-8 +# Copyright 2017 Virgil Dupras + +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + + +def preprocess_paths(paths): + if not isinstance(paths, list): + paths = [paths] + # Convert items such as pathlib paths to strings + paths = [path.__fspath__() if hasattr(path, "__fspath__") else path for path in paths] + return paths diff --git a/lib/send2trash/win/IFileOperationProgressSink.py b/lib/send2trash/win/IFileOperationProgressSink.py new file mode 100644 index 00000000..c8702dfb --- /dev/null +++ b/lib/send2trash/win/IFileOperationProgressSink.py @@ -0,0 +1,46 @@ +# Sample implementation of IFileOperationProgressSink that just prints +# some basic info + +import pythoncom +from win32com.shell import shell, shellcon +from win32com.server.policy import DesignatedWrapPolicy + + +class FileOperationProgressSink(DesignatedWrapPolicy): + _com_interfaces_ = [shell.IID_IFileOperationProgressSink] + _public_methods_ = [ + "StartOperations", + "FinishOperations", + "PreRenameItem", + "PostRenameItem", + "PreMoveItem", + "PostMoveItem", + "PreCopyItem", + "PostCopyItem", + "PreDeleteItem", + "PostDeleteItem", + "PreNewItem", + "PostNewItem", + "UpdateProgress", + "ResetTimer", + "PauseTimer", + "ResumeTimer", + ] + + def __init__(self): + self._wrap_(self) + self.newItem = None + + def PreDeleteItem(self, flags, item): + # Can detect cases where to stop via flags and condition below, however the operation + # does not actual stop, we can resort to raising an exception as that does stop things + # but that may need some additional considerations before implementing. + return 0 if flags & shellcon.TSF_DELETE_RECYCLE_IF_POSSIBLE else 0x80004005 # S_OK, or E_FAIL + + def PostDeleteItem(self, flags, item, hr_delete, newly_created): + if newly_created: + self.newItem = newly_created.GetDisplayName(shellcon.SHGDN_FORPARSING) + + +def create_sink(): + return pythoncom.WrapObject(FileOperationProgressSink(), shell.IID_IFileOperationProgressSink) diff --git a/lib/send2trash/win/__init__.py b/lib/send2trash/win/__init__.py new file mode 100644 index 00000000..e1fddf05 --- /dev/null +++ b/lib/send2trash/win/__init__.py @@ -0,0 +1,20 @@ +# Copyright 2017 Virgil Dupras + +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +from __future__ import unicode_literals +from platform import version + +# if windows is vista or newer and pywin32 is available use IFileOperation +modern = int(version().split(".", 1)[0]) >= 6 +if modern: + try: + # Attempt to use pywin32 to use IFileOperation + from send2trash.win.modern import send2trash + except ImportError: + modern = False +if not modern: + # use SHFileOperation as fallback + from send2trash.win.legacy import send2trash # noqa: F401 diff --git a/lib/send2trash/win/legacy.py b/lib/send2trash/win/legacy.py new file mode 100644 index 00000000..3aee7d9b --- /dev/null +++ b/lib/send2trash/win/legacy.py @@ -0,0 +1,194 @@ +# Copyright 2017 Virgil Dupras + +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +from __future__ import unicode_literals +import os.path as op + +from send2trash.compat import text_type +from send2trash.util import preprocess_paths + +from ctypes import ( + windll, + Structure, + byref, + c_uint, + create_unicode_buffer, + addressof, + GetLastError, + FormatError, +) +from ctypes.wintypes import HWND, UINT, LPCWSTR, BOOL + +kernel32 = windll.kernel32 +GetShortPathNameW = kernel32.GetShortPathNameW + +shell32 = windll.shell32 +SHFileOperationW = shell32.SHFileOperationW + + +class SHFILEOPSTRUCTW(Structure): + _fields_ = [ + ("hwnd", HWND), + ("wFunc", UINT), + ("pFrom", LPCWSTR), + ("pTo", LPCWSTR), + ("fFlags", c_uint), + ("fAnyOperationsAborted", BOOL), + ("hNameMappings", c_uint), + ("lpszProgressTitle", LPCWSTR), + ] + + +FO_MOVE = 1 +FO_COPY = 2 +FO_DELETE = 3 +FO_RENAME = 4 + +FOF_MULTIDESTFILES = 1 +FOF_SILENT = 4 +FOF_NOCONFIRMATION = 16 +FOF_ALLOWUNDO = 64 +FOF_NOERRORUI = 1024 + + +def convert_sh_file_opt_result(result): + # map overlapping values from SHFileOpterationW to approximate standard windows errors + # ref https://docs.microsoft.com/en-us/windows/win32/api/shellapi/nf-shellapi-shfileoperationw#return-value + # ref https://docs.microsoft.com/en-us/windows/win32/debug/system-error-codes--0-499- + results = { + 0x71: 0x50, # DE_SAMEFILE -> ERROR_FILE_EXISTS + 0x72: 0x57, # DE_MANYSRC1DEST -> ERROR_INVALID_PARAMETER + 0x73: 0x57, # DE_DIFFDIR -> ERROR_INVALID_PARAMETER + 0x74: 0x57, # DE_ROOTDIR -> ERROR_INVALID_PARAMETER + 0x75: 0x4C7, # DE_OPCANCELLED -> ERROR_CANCELLED + 0x76: 0x57, # DE_DESTSUBTREE -> ERROR_INVALID_PARAMETER + 0x78: 0x05, # DE_ACCESSDENIEDSRC -> ERROR_ACCESS_DENIED + 0x79: 0x6F, # DE_PATHTOODEEP -> ERROR_BUFFER_OVERFLOW + 0x7A: 0x57, # DE_MANYDEST -> ERROR_INVALID_PARAMETER + 0x7C: 0xA1, # DE_INVALIDFILES -> ERROR_BAD_PATHNAME + 0x7D: 0x57, # DE_DESTSAMETREE -> ERROR_INVALID_PARAMETER + 0x7E: 0xB7, # DE_FLDDESTISFILE -> ERROR_ALREADY_EXISTS + 0x80: 0xB7, # DE_FILEDESTISFLD -> ERROR_ALREADY_EXISTS + 0x81: 0x6F, # DE_FILENAMETOOLONG -> ERROR_BUFFER_OVERFLOW + 0x82: 0x13, # DE_DEST_IS_CDROM -> ERROR_WRITE_PROTECT + 0x83: 0x13, # DE_DEST_IS_DVD -> ERROR_WRITE_PROTECT + 0x84: 0x6F9, # DE_DEST_IS_CDRECORD -> ERROR_UNRECOGNIZED_MEDIA + 0x85: 0xDF, # DE_FILE_TOO_LARGE -> ERROR_FILE_TOO_LARGE + 0x86: 0x13, # DE_SRC_IS_CDROM -> ERROR_WRITE_PROTECT + 0x87: 0x13, # DE_SRC_IS_DVD -> ERROR_WRITE_PROTECT + 0x88: 0x6F9, # DE_SRC_IS_CDRECORD -> ERROR_UNRECOGNIZED_MEDIA + 0xB7: 0x6F, # DE_ERROR_MAX -> ERROR_BUFFER_OVERFLOW + 0x402: 0xA1, # UNKNOWN -> ERROR_BAD_PATHNAME + 0x10000: 0x1D, # ERRORONDEST -> ERROR_WRITE_FAULT + 0x10074: 0x57, # DE_ROOTDIR | ERRORONDEST -> ERROR_INVALID_PARAMETER + } + + if result in results.keys(): + return results[result] + else: + return result + + +def prefix_and_path(path): + r"""Guess the long-path prefix based on the kind of *path*. + Local paths (C:\folder\file.ext) and UNC names (\\server\folder\file.ext) + are handled. + + Return a tuple of the long-path prefix and the prefixed path. + """ + prefix, long_path = "\\\\?\\", path + + if not path.startswith(prefix): + if path.startswith("\\\\"): + # Likely a UNC name + prefix = "\\\\?\\UNC" + long_path = prefix + path[1:] + else: + # Likely a local path + long_path = prefix + path + elif path.startswith(prefix + "UNC\\"): + # UNC name with long-path prefix + prefix = "\\\\?\\UNC" + + return prefix, long_path + + +def get_awaited_path_from_prefix(prefix, path): + """Guess the correct path to pass to the SHFileOperationW() call. + The long-path prefix must be removed, so we should take care of + different long-path prefixes. + """ + if prefix == "\\\\?\\UNC": + # We need to prepend a backslash for UNC names, as it was removed + # in prefix_and_path(). + return "\\" + path[len(prefix) :] + return path[len(prefix) :] + + +def get_short_path_name(long_name): + prefix, long_path = prefix_and_path(long_name) + buf_size = GetShortPathNameW(long_path, None, 0) + # FIX: https://github.com/hsoft/send2trash/issues/31 + # If buffer size is zero, an error has occurred. + if not buf_size: + err_no = GetLastError() + raise WindowsError(err_no, FormatError(err_no), long_path) + output = create_unicode_buffer(buf_size) + GetShortPathNameW(long_path, output, buf_size) + return get_awaited_path_from_prefix(prefix, output.value) + + +def send2trash(paths): + paths = preprocess_paths(paths) + if not paths: + return + # convert data type + paths = [text_type(path, "mbcs") if not isinstance(path, text_type) else path for path in paths] + # convert to full paths + paths = [op.abspath(path) if not op.isabs(path) else path for path in paths] + # get short path to handle path length issues + short_paths = [get_short_path_name(path) for path in paths] + try: + s2t(short_paths) + except(BaseException, Exception): + s2t(paths) + + + +def s2t(paths): + fileop = SHFILEOPSTRUCTW() + fileop.hwnd = 0 + fileop.wFunc = FO_DELETE + # FIX: https://github.com/hsoft/send2trash/issues/17 + # Starting in python 3.6.3 it is no longer possible to use: + # LPCWSTR(path + '\0') directly as embedded null characters are no longer + # allowed in strings + # Workaround + # - create buffer of c_wchar[] (LPCWSTR is based on this type) + # - buffer is two c_wchar characters longer (double null terminator) + # - cast the address of the buffer to a LPCWSTR + # NOTE: based on how python allocates memory for these types they should + # always be zero, if this is ever not true we can go back to explicitly + # setting the last two characters to null using buffer[index] = '\0'. + # Additional note on another issue here, unicode_buffer expects length in + # bytes essentially, so having multi-byte characters causes issues if just + # passing pythons string length. Instead of dealing with this difference we + # just create a buffer then a new one with an extra null. Since the non-length + # specified version apparently stops after the first null, join with a space first. + buffer = create_unicode_buffer(" ".join(paths)) + # convert to a single string of null terminated paths + path_string = "\0".join(paths) + buffer = create_unicode_buffer(path_string, len(buffer) + 1) + fileop.pFrom = LPCWSTR(addressof(buffer)) + fileop.pTo = None + fileop.fFlags = FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_NOERRORUI | FOF_SILENT + fileop.fAnyOperationsAborted = 0 + fileop.hNameMappings = 0 + fileop.lpszProgressTitle = None + result = SHFileOperationW(byref(fileop)) + if result: + error = convert_sh_file_opt_result(result) + raise WindowsError(None, FormatError(error), paths, error) diff --git a/lib/send2trash/win/modern.py b/lib/send2trash/win/modern.py new file mode 100644 index 00000000..7927a898 --- /dev/null +++ b/lib/send2trash/win/modern.py @@ -0,0 +1,66 @@ +# Copyright 2017 Virgil Dupras + +# This software is licensed under the "BSD" License as described in the "LICENSE" file, +# which should be included with this package. The terms are also available at +# http://www.hardcoded.net/licenses/bsd_license + +from __future__ import unicode_literals +import os.path as op +from send2trash.compat import text_type +from send2trash.util import preprocess_paths +from platform import version +import pythoncom +import pywintypes +from win32com.shell import shell, shellcon +from send2trash.win.IFileOperationProgressSink import create_sink + + +def send2trash(paths): + paths = preprocess_paths(paths) + if not paths: + return + # convert data type + paths = [text_type(path, "mbcs") if not isinstance(path, text_type) else path for path in paths] + # convert to full paths + paths = [op.abspath(path) if not op.isabs(path) else path for path in paths] + # remove the leading \\?\ if present + paths = [path[4:] if path.startswith("\\\\?\\") else path for path in paths] + # Need to initialize the com before using + pythoncom.CoInitialize() + # create instance of file operation object + fileop = pythoncom.CoCreateInstance( + shell.CLSID_FileOperation, + None, + pythoncom.CLSCTX_ALL, + shell.IID_IFileOperation, + ) + # default flags to use + flags = shellcon.FOF_NOCONFIRMATION | shellcon.FOF_NOERRORUI | shellcon.FOF_SILENT | shellcon.FOFX_EARLYFAILURE + # determine rest of the flags based on OS version + # use newer recommended flags if available + if int(version().split(".", 1)[0]) >= 8: + flags |= 0x20000000 | 0x00080000 # FOFX_ADDUNDORECORD win 8+ # FOFX_RECYCLEONDELETE win 8+ + else: + flags |= shellcon.FOF_ALLOWUNDO + # set the flags + fileop.SetOperationFlags(flags) + # actually try to perform the operation, this section may throw a + # pywintypes.com_error which does not seem to create as nice of an + # error as OSError so wrapping with try to convert + sink = create_sink() + try: + for path in paths: + item = shell.SHCreateItemFromParsingName(path, None, shell.IID_IShellItem) + fileop.DeleteItem(item, sink) + result = fileop.PerformOperations() + aborted = fileop.GetAnyOperationsAborted() + # if non-zero result or aborted throw an exception + if result or aborted: + raise OSError(None, None, paths, result) + except pywintypes.com_error as error: + # convert to standard OS error, allows other code to get a + # normal errno + raise OSError(None, error.strerror, path, error.hresult) + finally: + # Need to make sure we call this once fore every init + pythoncom.CoUninitialize()