1
0
mirror of https://github.com/arsenetar/send2trash.git synced 2026-01-25 16:11:39 +00:00

16 Commits

Author SHA1 Message Date
49bc438546 Update files to release a new package 2020-06-18 21:49:42 -05:00
2e9fa38f56 Fix some flake8 errors and cleanup
- Fix some flake8 formatting errors
- Auto-format files edited
- Also ignore some flake8 errors when they are intented
- Update .gitignore to ingore local env
2020-06-18 21:47:06 -05:00
1e099724c5 Fix a typo in tox.ini for windows 2020-06-11 23:23:54 -05:00
6ac20bc4f6 Fix new issues with unit tests
- Create custom test suite based on platform
- Use the custom suite for all tests
- Update tox.ini to install pywin32
2020-06-11 23:20:16 -05:00
e3d2be3243 Update windows tests to test both versions
This fixes #44 by testing the automatic import, the legacy version, and
the modern version directtly.
2020-06-11 22:42:00 -05:00
d078554052 Windows Performance Improvement & Multi-Item support (#43)
* Initial IFileOperation for Windows

- Try using IFileOperation instead of SHFileOperation
  - Use pywin32 to accomplish this
  - Implement fallback when pywin32 not available
- Handles paths like `C:\` just fine bu the `\\?\` paths in the test
  cause issue
- Add batching for IFileOperation version (performance)
- Minor formatting applied by editor

* Fix issue with paths starting with \\?\

- Strip these characters off if present just like old implementation

* Add windows version check, legacy list support

- Add check for windows version for IFileOperation
- Add list support to legacy version
- Remove some debugging code
- Fix bug in path converson

Not sure if there is a better way to layout this file

* Split plat_win into legacy and modern

* Update other platforms for list support

Formatter also ran on these so some other minor changes.

* Add unit tests for multi-file calls
2020-06-03 12:49:41 -04:00
Matthew D. Scholefield
9ede898c3e Create __main__.py (Fixes #15) (#38)
This adds a main method that mimics the behavior of `rm`. It can be called via `python -m send2trash somefile`.
2020-05-27 07:52:00 -04:00
sharkykh
66afce7252 Fix silently failing on Windows (#33)
* Fix #31: Silently failing on Windows

* Update Windows test

* Fix test folders not getting removed
2019-04-30 12:28:09 -04:00
sharkykh
8f684a9c8b Add Python 3.7 and Windows to Travis-CI (#34)
* Add Python 3.7 and Windows to Travis-CI

* Reorder environments
2019-04-30 12:22:28 -04:00
Virgil Dupras
1c32d471f2 README: Maintainer wanted 2019-01-04 10:27:27 -05:00
Virgil Dupras
74352462f5 Fix broken tests on py2 2018-07-26 08:30:39 -04:00
Virgil Dupras
0d7b4b4ad9 v1.5.0 2018-02-16 09:57:27 -05:00
Thomas Kluyver
1dded4f572 Raise TrashPermissionError from gio backend (#22) 2018-02-16 09:30:26 -05:00
Mickaël Schoentgen
020d05979d Windows: Workaround for long paths (#23)
By using the short path version of a file, we can
manage to move long paths to the trash.

Limitations:
1/ If the final short path is longer than what
    `SHFileOperationW` can handle, it will fail
2/ Still not able to trash long path from another
    drive, ie: trying to delete C:\temp\foo.txt
    while the script is running from D:\trash.py
2018-02-16 09:07:05 -05:00
Thomas Kluyver
6b0bd46036 Define TrashPermissionError (#21) 2018-02-06 17:28:47 -05:00
Nicholas Bollweg
f6897609ba Include LICENSE in package (#19) 2018-01-06 08:19:31 -05:00
21 changed files with 784 additions and 206 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@
.DS_Store
/.tox
__pycache__
/env

View File

@@ -1,10 +1,28 @@
language: python
python:
- "2.7"
- "3.4"
- "3.5"
- "3.6"
matrix:
include:
- os: windows
language: sh
python: 3
# Perform the manual steps on windows to install python3
before_install:
- choco install python3 --params "/InstallDir:C:\Python"
- export PATH="/c/Python:/c/Python/Scripts:$PATH"
- python -m pip install --upgrade pip
- python -m pip install pywin32
before_script:
- export TOXENV=py3-win
- python: "2.7"
- python: "3.4"
- python: "3.5"
- python: "3.6"
# Obtain Python 3.7 from xenial as per https://github.com/travis-ci/travis-ci/issues/9815
- python: "3.7"
dist: xenial
install:
- "pip install tox"
- pip install tox
before_script:
- export TOXENV=$(echo py$TRAVIS_PYTHON_VERSION | tr -d .)
script:
- "tox -e $(echo py$TRAVIS_PYTHON_VERSION | tr -d .)"
- tox

View File

@@ -1,6 +1,21 @@
Changes
=======
Version 1.6.0b1 -- 2020/06/18
-----------------------------
* Add main method which allows calling via ``python -m send2trash somefile``
* Windows: Add support for using IFileOperation when pywin32 is present on Vista and newer
* Add support for passing multiple files at once in a list
* Windows: Batch multi-file calls to improve performance (#42)
* Windows: Fix issue with SHFileOperation failing silently when path is not found (#33)
Version 1.5.0 -- 2018/02/16
---------------------------
* More specific error when failing to create XDG fallback trash directory (#20)
* Windows: Workaround for long paths (#23)
Version 1.4.2 -- 2017/11/17
---------------------------

View File

@@ -1 +1 @@
include CHANGES.rst
include CHANGES.rst LICENSE

View File

@@ -3,15 +3,22 @@ Send2Trash -- Send files to trash on all platforms
==================================================
Send2Trash is a small package that sends files to the Trash (or Recycle Bin) *natively* and on
*all platforms*. On OS X, it uses native ``FSMoveObjectToTrashSync`` Cocoa calls, on Windows, it
uses native (and ugly) ``SHFileOperation`` win32 calls. On other platforms, if `PyGObject`_ and
`GIO`_ are available, it will use this. Otherwise, it will fallback to its own implementation
of the `trash specifications from freedesktop.org`_.
*all platforms*. On OS X, it uses native ``FSMoveObjectToTrashSync`` Cocoa calls. On Windows, it
uses native ``IFileOperation`` call if on Vista or newer and pywin32 is installed or falls back
to ``SHFileOperation`` calls. On other platforms, if `PyGObject`_ and `GIO`_ are available, it
will use this. Otherwise, it will fallback to its own implementation of the `trash specifications
from freedesktop.org`_.
``ctypes`` is used to access native libraries, so no compilation is necessary.
Send2Trash supports Python 2.7 and up (Python 3 is supported).
Status: Additional Help Welcome
-------------------------------
Additional help is welcome for supporting this package. Specifically help with the OSX and Linux
issues and fixes would be most appreciated.
Installation
------------
@@ -19,7 +26,7 @@ You can download it with pip::
pip install Send2Trash
or you can download the source from http://github.com/hsoft/send2trash and install it with::
or you can download the source from http://github.com/arsenetar/send2trash and install it with::
>>> python setup.py install
@@ -28,8 +35,17 @@ Usage
>>> from send2trash import send2trash
>>> send2trash('some_file')
>>> send2trash(['some_file1', 'some_file2'])
When there's a problem ``OSError`` is raised.
On Freedesktop platforms (Linux, BSD, etc.), you may not be able to efficiently
trash some files. In these cases, an exception ``send2trash.TrashPermissionError``
is raised, so that the application can handle this case. This inherits from
``PermissionError`` (``OSError`` on Python 2). Specifically, this affects
files on a different device to the user's home directory, where the root of the
device does not have a ``.Trash`` directory, and we don't have permission to
create a ``.Trash-$UID`` directory.
For any other problem, ``OSError`` is raised.
.. _PyGObject: https://wiki.gnome.org/PyGObject
.. _GIO: https://developer.gnome.org/gio/

View File

@@ -6,9 +6,11 @@
import sys
if sys.platform == 'darwin':
from .exceptions import TrashPermissionError # noqa: F401
if sys.platform == "darwin":
from .plat_osx import send2trash
elif sys.platform == 'win32':
elif sys.platform == "win32":
from .plat_win import send2trash
else:
try:
@@ -16,4 +18,4 @@ else:
from .plat_gio import send2trash
except ImportError:
# Oh well, let's fallback to our own Freedesktop trash implementation
from .plat_other import send2trash
from .plat_other import send2trash # noqa: F401

33
send2trash/__main__.py Normal file
View File

@@ -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()

View File

@@ -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

26
send2trash/exceptions.py Normal file
View File

@@ -0,0 +1,26 @@
import errno
from .compat import PY3
if PY3:
_permission_error = PermissionError
else:
_permission_error = OSError
class TrashPermissionError(_permission_error):
"""A permission error specific to a trash directory.
Raising this error indicates that permissions prevent us efficiently
trashing a file, although we might still have permission to delete it.
This is *not* used when permissions prevent removing the file itself:
that will be raised as a regular PermissionError (OSError on Python 2).
Application code that catches this may try to simply delete the file,
or prompt the user to decide, or (on Freedesktop platforms), move it to
'home trash' as a fallback. This last option probably involves copying the
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)

View File

@@ -5,10 +5,19 @@
# http://www.hardcoded.net/licenses/bsd_license
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:
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)

View File

@@ -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)

View File

@@ -16,11 +16,13 @@
from __future__ import unicode_literals
import errno
import sys
import os
import os.path as op
from datetime import datetime
import stat
try:
from urllib.parse import quote
except ImportError:
@@ -28,33 +30,38 @@ except ImportError:
from urllib import quote
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)
@@ -62,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]
@@ -73,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)
@@ -91,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.
@@ -124,63 +139,74 @@ 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)
# Try to make the directory, if we can't the OSError exception will escape
# be thrown out of send2trash.
check_create(trash_dir)
# Try to make the directory, if we lack permission, raise TrashPermissionError
try:
check_create(trash_dir)
except OSError as e:
if e.errno == errno.EACCES:
raise TrashPermissionError(e.filename)
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)

View File

@@ -5,66 +5,16 @@
# http://www.hardcoded.net/licenses/bsd_license
from __future__ import unicode_literals
from platform import version
from ctypes import (windll, Structure, byref, c_uint,
create_unicode_buffer, sizeof, addressof)
from ctypes.wintypes import HWND, UINT, LPCWSTR, BOOL
import os.path as op
from .compat import text_type
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 send2trash(path):
if not isinstance(path, text_type):
path = text_type(path, 'mbcs')
if not op.isabs(path):
path = op.abspath(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'.
buffer = create_unicode_buffer(path, len(path)+2)
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:
raise WindowsError(None, None, path, result)
# if windows is vista or newer and pywin32 is available use IFileOperation
if int(version().split(".", 1)[0]) >= 6:
try:
# Attempt to use pywin32 to use IFileOperation
from .plat_win_modern import send2trash
except ImportError:
# use SHFileOperation as fallback
from .plat_win_legacy import send2trash
else:
# use SHFileOperation as fallback
from .plat_win_legacy import send2trash # noqa: F401

View File

@@ -0,0 +1,105 @@
# 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 .compat import text_type
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 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.value[4:] # Remove '\\?\' for SHFileOperationW
def send2trash(paths):
if not isinstance(paths, list):
paths = [paths]
# 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
paths = [get_short_path_name(path) for path in paths]
# convert to a single string of null terminated paths
paths = "\0".join(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'.
buffer = create_unicode_buffer(paths, len(paths) + 2)
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:
raise WindowsError(result, FormatError(result), paths)

View File

@@ -0,0 +1,65 @@
# 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 .compat import text_type
from platform import version
import pythoncom
import pywintypes
from win32com.shell import shell, shellcon
def send2trash(paths):
if not isinstance(paths, list):
paths = [paths]
# 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]
# 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 # FOFX_ADDUNDORECORD win 8+
| 0x00080000 # 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
try:
for path in paths:
item = shell.SHCreateItemFromParsingName(path, None, shell.IID_IShellItem)
fileop.DeleteItem(item)
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)

View File

@@ -1,33 +1,39 @@
from setuptools import setup
CLASSIFIERS = [
'Development Status :: 5 - Production/Stable',
'Intended Audience :: Developers',
'License :: OSI Approved :: BSD License',
'Operating System :: MacOS :: MacOS X',
'Operating System :: Microsoft :: Windows',
'Operating System :: POSIX',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Topic :: Desktop Environment :: File Managers',
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"License :: OSI Approved :: BSD License",
"Operating System :: MacOS :: MacOS X",
"Operating System :: Microsoft :: Windows",
"Operating System :: POSIX",
"Programming Language :: Python :: 2.7",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.4",
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Topic :: Desktop Environment :: File Managers",
]
LONG_DESCRIPTION = open('README.rst', 'rt').read() + '\n\n' + open('CHANGES.rst', 'rt').read()
LONG_DESCRIPTION = (
open("README.rst", "rt").read() + "\n\n" + open("CHANGES.rst", "rt").read()
)
setup(
name='Send2Trash',
version='1.4.2',
author='Virgil Dupras',
author_email='hsoft@hardcoded.net',
packages=['send2trash'],
name="Send2Trash",
version="1.6.0b1",
author="Andrew Senetar",
author_email="arsenetar@voltaicideas.net",
packages=["send2trash"],
scripts=[],
test_suite='tests',
url='https://github.com/hsoft/send2trash',
license='BSD License',
description='Send file to trash natively under Mac OS X, Windows and Linux.',
test_suite="tests",
url="https://github.com/arsenetar/send2trash",
license="BSD License",
description="Send file to trash natively under Mac OS X, Windows and Linux.",
long_description=LONG_DESCRIPTION,
classifiers=CLASSIFIERS,
extras_require={"win32": ["pywin32"]},
project_urls={"Bug Reports": "https://github.com/arsenetar/send2trash/issues"},
)

View File

@@ -0,0 +1,18 @@
import sys
import unittest
def TestSuite():
suite = unittest.TestSuite()
loader = unittest.TestLoader()
if sys.platform == "win32":
from . import test_plat_win
suite.addTests(loader.loadTestsFromModule(test_plat_win))
else:
from . import test_script_main
from . import test_plat_other
suite.addTests(loader.loadTestsFromModule(test_script_main))
suite.addTests(loader.loadTestsFromModule(test_plat_other))
return suite

View File

@@ -6,23 +6,32 @@ from os import path as op
import send2trash.plat_other
from send2trash.plat_other import send2trash as s2t
from send2trash.compat import PY3
from configparser import ConfigParser
try:
from configparser import ConfigParser
except ImportError:
# py2
from ConfigParser import ConfigParser
from tempfile import mkdtemp, NamedTemporaryFile, mktemp
import shutil
import stat
import sys
# Could still use cleaning up. But no longer relies on ramfs.
HOMETRASH = send2trash.plat_other.HOMETRASH
def touch(path):
with open(path, 'a'):
with open(path, "a"):
os.utime(path, None)
class TestHomeTrash(unittest.TestCase):
def setUp(self):
self.file = NamedTemporaryFile(
dir=op.expanduser("~"), prefix='send2trash_test', delete=False)
dir=op.expanduser("~"), prefix="send2trash_test", delete=False
)
def test_trash(self):
s2t(self.file.name)
@@ -30,8 +39,35 @@ class TestHomeTrash(unittest.TestCase):
def tearDown(self):
name = op.basename(self.file.name)
os.remove(op.join(HOMETRASH, 'files', name))
os.remove(op.join(HOMETRASH, 'info', name+'.trashinfo'))
os.remove(op.join(HOMETRASH, "files", name))
os.remove(op.join(HOMETRASH, "info", name + ".trashinfo"))
class TestHomeMultiTrash(unittest.TestCase):
def setUp(self):
self.files = list(
map(
lambda index: NamedTemporaryFile(
dir=op.expanduser("~"),
prefix="send2trash_test{}".format(index),
delete=False,
),
range(10),
)
)
def test_multitrash(self):
filenames = [file.name for file in self.files]
s2t(filenames)
self.assertFalse(any([op.exists(filename) for filename in filenames]))
def tearDown(self):
filenames = [op.basename(file.name) for file in self.files]
[os.remove(op.join(HOMETRASH, "files", filename)) for filename in filenames]
[
os.remove(op.join(HOMETRASH, "info", filename + ".trashinfo"))
for filename in filenames
]
def _filesys_enc():
@@ -39,11 +75,12 @@ def _filesys_enc():
# Get canonical name of codec
return codecs.lookup(enc).name
@unittest.skipIf(_filesys_enc() == 'ascii', 'ASCII filesystem')
@unittest.skipIf(_filesys_enc() == "ascii", "ASCII filesystem")
class TestUnicodeTrash(unittest.TestCase):
def setUp(self):
self.name = u'send2trash_tést1'
self.file = op.join(op.expanduser(b'~'), self.name.encode('utf-8'))
self.name = u"send2trash_tést1"
self.file = op.join(op.expanduser(b"~"), self.name.encode("utf-8"))
touch(self.file)
def test_trash_bytes(self):
@@ -58,10 +95,11 @@ class TestUnicodeTrash(unittest.TestCase):
if op.exists(self.file):
os.remove(self.file)
trash_file = op.join(HOMETRASH, 'files', self.name)
trash_file = op.join(HOMETRASH, "files", self.name)
if op.exists(trash_file):
os.remove(trash_file)
os.remove(op.join(HOMETRASH, 'info', self.name+'.trashinfo'))
os.remove(op.join(HOMETRASH, "info", self.name + ".trashinfo"))
#
# Tests for files on some other volume than the user's home directory.
@@ -72,25 +110,31 @@ class TestUnicodeTrash(unittest.TestCase):
#
class TestExtVol(unittest.TestCase):
def setUp(self):
self.trashTopdir = mkdtemp(prefix='s2t')
self.trashTopdir = mkdtemp(prefix="s2t")
if PY3:
trashTopdir_b = os.fsencode(self.trashTopdir)
else:
trashTopdir_b = self.trashTopdir
self.fileName = 'test.txt'
self.fileName = "test.txt"
self.filePath = op.join(self.trashTopdir, self.fileName)
touch(self.filePath)
self.old_ismount = old_ismount = op.ismount
self.old_getdev = send2trash.plat_other.get_dev
def s_getdev(path):
from send2trash.plat_other import is_parent
st = os.lstat(path)
if is_parent(self.trashTopdir, path):
return 'dev'
return "dev"
return st.st_dev
def s_ismount(path):
if op.realpath(path) in (op.realpath(self.trashTopdir), op.realpath(trashTopdir_b)):
if op.realpath(path) in (
op.realpath(self.trashTopdir),
op.realpath(trashTopdir_b),
):
return True
return old_ismount(path)
@@ -102,23 +146,40 @@ class TestExtVol(unittest.TestCase):
send2trash.plat_other.os.path.ismount = self.old_ismount
shutil.rmtree(self.trashTopdir)
class TestTopdirTrash(TestExtVol):
def setUp(self):
TestExtVol.setUp(self)
# Create a .Trash dir w/ a sticky bit
self.trashDir = op.join(self.trashTopdir, '.Trash')
os.mkdir(self.trashDir, 0o777|stat.S_ISVTX)
self.trashDir = op.join(self.trashTopdir, ".Trash")
os.mkdir(self.trashDir, 0o777 | stat.S_ISVTX)
def test_trash(self):
s2t(self.filePath)
self.assertFalse(op.exists(self.filePath))
self.assertTrue(op.exists(op.join(self.trashDir, str(os.getuid()), 'files', self.fileName)))
self.assertTrue(op.exists(op.join(self.trashDir, str(os.getuid()), 'info', self.fileName + '.trashinfo')))
self.assertTrue(
op.exists(op.join(self.trashDir, str(os.getuid()), "files", self.fileName))
)
self.assertTrue(
op.exists(
op.join(
self.trashDir,
str(os.getuid()),
"info",
self.fileName + ".trashinfo",
)
)
)
# info relative path (if another test is added, with the same fileName/Path,
# then it gets renamed etc.)
cfg = ConfigParser()
cfg.read(op.join(self.trashDir, str(os.getuid()), 'info', self.fileName + '.trashinfo'))
self.assertEqual(self.fileName, cfg.get('Trash Info', 'Path', raw=True))
cfg.read(
op.join(
self.trashDir, str(os.getuid()), "info", self.fileName + ".trashinfo"
)
)
self.assertEqual(self.fileName, cfg.get("Trash Info", "Path", raw=True))
# Test .Trash-UID
class TestTopdirTrashFallback(TestExtVol):
@@ -126,13 +187,23 @@ class TestTopdirTrashFallback(TestExtVol):
touch(self.filePath)
s2t(self.filePath)
self.assertFalse(op.exists(self.filePath))
self.assertTrue(op.exists(op.join(self.trashTopdir, '.Trash-' + str(os.getuid()), 'files', self.fileName)))
self.assertTrue(
op.exists(
op.join(
self.trashTopdir,
".Trash-" + str(os.getuid()),
"files",
self.fileName,
)
)
)
# Test failure
class TestTopdirFailure(TestExtVol):
def setUp(self):
TestExtVol.setUp(self)
os.chmod(self.trashTopdir, 0o500) # not writable to induce the exception
os.chmod(self.trashTopdir, 0o500) # not writable to induce the exception
def test_trash(self):
with self.assertRaises(OSError):
@@ -140,9 +211,10 @@ class TestTopdirFailure(TestExtVol):
self.assertTrue(op.exists(self.filePath))
def tearDown(self):
os.chmod(self.trashTopdir, 0o700) # writable to allow deletion
os.chmod(self.trashTopdir, 0o700) # writable to allow deletion
TestExtVol.tearDown(self)
# Make sure it will find the mount point properly for a file in a symlinked path
class TestSymlink(TestExtVol):
def setUp(self):
@@ -150,21 +222,31 @@ class TestSymlink(TestExtVol):
# Use mktemp (race conditioney but no symlink equivalent)
# Since is_parent uses realpath(), and our getdev uses is_parent,
# this should work
self.slDir = mktemp(prefix='s2t', dir=op.expanduser('~'))
self.slDir = mktemp(prefix="s2t", dir=op.expanduser("~"))
os.mkdir(op.join(self.trashTopdir, 'subdir'), 0o700)
self.filePath = op.join(self.trashTopdir, 'subdir', self.fileName)
os.mkdir(op.join(self.trashTopdir, "subdir"), 0o700)
self.filePath = op.join(self.trashTopdir, "subdir", self.fileName)
touch(self.filePath)
os.symlink(op.join(self.trashTopdir, 'subdir'), self.slDir)
os.symlink(op.join(self.trashTopdir, "subdir"), self.slDir)
def test_trash(self):
s2t(op.join(self.slDir, self.fileName))
self.assertFalse(op.exists(self.filePath))
self.assertTrue(op.exists(op.join(self.trashTopdir, '.Trash-' + str(os.getuid()), 'files', self.fileName)))
self.assertTrue(
op.exists(
op.join(
self.trashTopdir,
".Trash-" + str(os.getuid()),
"files",
self.fileName,
)
)
)
def tearDown(self):
os.remove(self.slDir)
TestExtVol.tearDown(self)
if __name__ == '__main__':
if __name__ == "__main__":
unittest.main()

154
tests/test_plat_win.py Normal file
View File

@@ -0,0 +1,154 @@
# coding: utf-8
import os
import shutil
import sys
import unittest
from os import path as op
from tempfile import gettempdir
from send2trash import send2trash as s2t
# import the two versions as well as the "automatic" version
from send2trash.plat_win_modern import send2trash as s2t_modern
from send2trash.plat_win_legacy import send2trash as s2t_legacy
@unittest.skipIf(sys.platform != "win32", "Windows only")
class TestNormal(unittest.TestCase):
def setUp(self):
self.dirname = "\\\\?\\" + op.join(gettempdir(), "python.send2trash")
self.file = op.join(self.dirname, "testfile.txt")
self._create_tree(self.file)
self.files = [
op.join(self.dirname, "testfile{}.txt".format(index)) for index in range(10)
]
[self._create_tree(file) for file in self.files]
def tearDown(self):
shutil.rmtree(self.dirname, ignore_errors=True)
def _create_tree(self, path):
dirname = op.dirname(path)
if not op.isdir(dirname):
os.makedirs(dirname)
with open(path, "w") as writer:
writer.write("send2trash test")
def _trash_file(self, fcn):
fcn(self.file)
self.assertFalse(op.exists(self.file))
def _trash_multifile(self, fcn):
fcn(self.files)
self.assertFalse(any([op.exists(file) for file in self.files]))
def _file_not_found(self, fcn):
file = op.join(self.dirname, "otherfile.txt")
self.assertRaises(WindowsError, fcn, file)
def test_trash_file(self):
self._trash_file(s2t)
def test_trash_multifile(self):
self._trash_multifile(s2t)
def test_file_not_found(self):
self._file_not_found(s2t)
def test_trash_file_modern(self):
self._trash_file(s2t_modern)
def test_trash_multifile_modern(self):
self._trash_multifile(s2t_modern)
def test_file_not_found_modern(self):
self._file_not_found(s2t_modern)
def test_trash_file_legacy(self):
self._trash_file(s2t_legacy)
def test_trash_multifile_legacy(self):
self._trash_multifile(s2t_legacy)
def test_file_not_found_legacy(self):
self._file_not_found(s2t_legacy)
@unittest.skipIf(sys.platform != "win32", "Windows only")
class TestLongPath(unittest.TestCase):
def setUp(self):
self.functions = {s2t: "auto", s2t_legacy: "legacy", s2t_modern: "modern"}
filename = "A" * 100
self.dirname = "\\\\?\\" + op.join(gettempdir(), filename)
path = op.join(
self.dirname,
filename,
filename, # From there, the path is not trashable from Explorer
filename,
filename + "{}.txt",
)
self.file = path.format("")
self._create_tree(self.file)
self.files = [path.format(index) for index in range(10)]
[self._create_tree(file) for file in self.files]
def tearDown(self):
shutil.rmtree(self.dirname, ignore_errors=True)
def _create_tree(self, path):
dirname = op.dirname(path)
if not op.isdir(dirname):
os.makedirs(dirname)
with open(path, "w") as writer:
writer.write("Looong filename!")
def _trash_file(self, fcn):
fcn(self.file)
self.assertFalse(op.exists(self.file))
def _trash_multifile(self, fcn):
fcn(self.files)
self.assertFalse(any([op.exists(file) for file in self.files]))
def _trash_folder(self, fcn):
fcn(self.dirname)
self.assertFalse(op.exists(self.dirname))
def test_trash_file(self):
self._trash_file(s2t)
def test_trash_multifile(self):
self._trash_multifile(s2t)
@unittest.skipIf(
op.splitdrive(os.getcwd())[0] != op.splitdrive(gettempdir())[0],
"Cannot trash long path from other drive",
)
def test_trash_folder(self):
self._trash_folder(s2t)
def test_trash_file_modern(self):
self._trash_file(s2t_modern)
def test_trash_multifile_modern(self):
self._trash_multifile(s2t_modern)
@unittest.skipIf(
op.splitdrive(os.getcwd())[0] != op.splitdrive(gettempdir())[0],
"Cannot trash long path from other drive",
)
def test_trash_folder_modern(self):
self._trash_folder(s2t_modern)
def test_trash_file_legacy(self):
self._trash_file(s2t_legacy)
def test_trash_multifile_legacy(self):
self._trash_multifile(s2t_legacy)
@unittest.skipIf(
op.splitdrive(os.getcwd())[0] != op.splitdrive(gettempdir())[0],
"Cannot trash long path from other drive",
)
def test_trash_folder_legacy(self):
self._trash_folder(s2t_legacy)

32
tests/test_script_main.py Normal file
View File

@@ -0,0 +1,32 @@
# encoding: utf-8
import os
import unittest
from tempfile import NamedTemporaryFile
from os import path as op
from send2trash.__main__ import main as trash_main
from tests.test_plat_other import HOMETRASH
class TestMainTrash(unittest.TestCase):
def setUp(self):
self.file = NamedTemporaryFile(dir=op.expanduser('~'), prefix='send2trash_test', delete=False)
def test_trash(self):
trash_main(['-v', self.file.name])
self.assertFalse(op.exists(self.file.name))
def test_no_args(self):
self.assertRaises(SystemExit, trash_main, [])
self.assertRaises(SystemExit, trash_main, ['-v'])
self.assertTrue(op.exists(self.file.name))
trash_main([self.file.name]) # Trash the file so tearDown runs properly
def tearDown(self):
name = op.basename(self.file.name)
os.remove(op.join(HOMETRASH, 'files', name))
os.remove(op.join(HOMETRASH, 'info', name + '.trashinfo'))
if __name__ == '__main__':
unittest.main()

16
tox.ini
View File

@@ -1,11 +1,23 @@
[tox]
envlist = py27,py34,py35,py36
envlist = py27,py34,py35,py36,py3-win
skip_missing_interpreters = True
[testenv]
platform = linux
commands =
python setup.py test
python setup.py test --test-suite tests.TestSuite
[testenv:py3-win]
platform = win
commands =
python -m pip install pywin32
python setup.py test --test-suite tests.TestSuite
[testenv:py27]
deps =
configparser
[flake8]
exclude = .tox,env,build
max-line-length = 120
ignore = E731,E203,E501,W503