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

11 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
20 changed files with 706 additions and 242 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,15 @@
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
---------------------------

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,6 +35,7 @@ Usage
>>> from send2trash import send2trash
>>> send2trash('some_file')
>>> send2trash(['some_file1', 'some_file2'])
On Freedesktop platforms (Linux, BSD, etc.), you may not be able to efficiently
trash some files. In these cases, an exception ``send2trash.TrashPermissionError``

View File

@@ -6,11 +6,11 @@
import sys
from .exceptions import TrashPermissionError
from .exceptions import TrashPermissionError # noqa: F401
if sys.platform == 'darwin':
if sys.platform == "darwin":
from .plat_osx import send2trash
elif sys.platform == 'win32':
elif sys.platform == "win32":
from .plat_win import send2trash
else:
try:
@@ -18,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

View File

@@ -6,6 +6,7 @@ if PY3:
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)

View File

@@ -7,7 +7,11 @@
from gi.repository import GObject, Gio
from .exceptions import TrashPermissionError
def send2trash(path):
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)
@@ -15,5 +19,5 @@ def send2trash(path):
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 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,17 +28,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')
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)

View File

@@ -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:
@@ -35,25 +36,29 @@ try:
fsencode = os.fsencode # Python 3
fsdecode = os.fsdecode
except AttributeError:
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
@@ -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]
@@ -80,11 +87,13 @@ def info_for(src, topdir):
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,18 +102,21 @@ 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
@@ -113,6 +125,7 @@ def find_mount_point(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,26 +159,32 @@ 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):
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__'):
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))
raise TypeError("str, bytes or PathLike expected, not %r" % type(path))
if not op.exists(path_b):
raise OSError("File not found: %s" % path)
@@ -178,7 +198,7 @@ def send2trash(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'~'))
trash_dev = get_dev(op.expanduser(b"~"))
if path_dev == trash_dev:
topdir = XDG_DATA_HOME

View File

@@ -5,82 +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, addressof)
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)
output = create_unicode_buffer(buf_size)
GetShortPathNameW(long_name, output, buf_size)
return output.value[4:] # Remove '\\?\' for SHFileOperationW
def send2trash(path):
if not isinstance(path, text_type):
path = text_type(path, 'mbcs')
if not op.isabs(path):
path = op.abspath(path)
path = get_short_path_name(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.5.0',
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,7 +187,17 @@ 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):
@@ -143,6 +214,7 @@ class TestTopdirFailure(TestExtVol):
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()

View File

@@ -1,5 +1,6 @@
# coding: utf-8
import os
import shutil
import sys
import unittest
from os import path as op
@@ -7,40 +8,147 @@ 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')
@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):
filename = 'A' * 100
self.dirname = '\\\\?\\' + os.path.join(gettempdir(), filename)
self.file = os.path.join(
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')
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):
try:
os.remove(self.dirname)
except OSError:
pass
shutil.rmtree(self.dirname, ignore_errors=True)
def _create_tree(self, path):
dirname = os.path.dirname(path)
if not os.path.isdir(dirname):
dirname = op.dirname(path)
if not op.isdir(dirname):
os.makedirs(dirname)
with open(path, 'w') as writer:
writer.write('Looong filename!')
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):
s2t(self.file)
self.assertFalse(op.exists(self.file))
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')
"Cannot trash long path from other drive",
)
def test_trash_folder(self):
s2t(self.dirname)
self.assertFalse(op.exists(self.dirname))
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