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 .DS_Store
/.tox /.tox
__pycache__ __pycache__
/env

View File

@@ -1,10 +1,28 @@
language: python language: python
python: matrix:
- "2.7" include:
- "3.4" - os: windows
- "3.5" language: sh
- "3.6" 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: install:
- "pip install tox" - pip install tox
before_script:
- export TOXENV=$(echo py$TRAVIS_PYTHON_VERSION | tr -d .)
script: script:
- "tox -e $(echo py$TRAVIS_PYTHON_VERSION | tr -d .)" - tox

View File

@@ -1,6 +1,21 @@
Changes 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 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 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 *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 uses native ``IFileOperation`` call if on Vista or newer and pywin32 is installed or falls back
`GIO`_ are available, it will use this. Otherwise, it will fallback to its own implementation to ``SHFileOperation`` calls. On other platforms, if `PyGObject`_ and `GIO`_ are available, it
of the `trash specifications from freedesktop.org`_. 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. ``ctypes`` is used to access native libraries, so no compilation is necessary.
Send2Trash supports Python 2.7 and up (Python 3 is supported). 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 Installation
------------ ------------
@@ -19,7 +26,7 @@ You can download it with pip::
pip install Send2Trash 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 >>> python setup.py install
@@ -28,8 +35,17 @@ Usage
>>> from send2trash import send2trash >>> from send2trash import send2trash
>>> send2trash('some_file') >>> 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 .. _PyGObject: https://wiki.gnome.org/PyGObject
.. _GIO: https://developer.gnome.org/gio/ .. _GIO: https://developer.gnome.org/gio/

View File

@@ -1,14 +1,16 @@
# Copyright 2013 Hardcoded Software (http://www.hardcoded.net) # Copyright 2013 Hardcoded Software (http://www.hardcoded.net)
# This software is licensed under the "BSD" License as described in the "LICENSE" file, # 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 # which should be included with this package. The terms are also available at
# http://www.hardcoded.net/licenses/bsd_license # http://www.hardcoded.net/licenses/bsd_license
import sys import sys
if sys.platform == 'darwin': from .exceptions import TrashPermissionError # noqa: F401
if sys.platform == "darwin":
from .plat_osx import send2trash from .plat_osx import send2trash
elif sys.platform == 'win32': elif sys.platform == "win32":
from .plat_win import send2trash from .plat_win import send2trash
else: else:
try: try:
@@ -16,4 +18,4 @@ else:
from .plat_gio import send2trash from .plat_gio import send2trash
except ImportError: except ImportError:
# Oh well, let's fallback to our own Freedesktop trash implementation # 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 will be unset under Windows, but then again we're not supposed to use it.
environb = os.environb environb = os.environb
else: else:
text_type = unicode text_type = unicode # noqa: F821
binary_type = str binary_type = str
environb = os.environ 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 # http://www.hardcoded.net/licenses/bsd_license
from gi.repository import GObject, Gio from gi.repository import GObject, Gio
from .exceptions import TrashPermissionError
def send2trash(path):
try: def send2trash(paths):
f = Gio.File.new_for_path(path) if not isinstance(paths, list):
f.trash(cancellable=None) paths = [paths]
except GObject.GError as e: for path in paths:
raise OSError(e.message) 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 from .compat import binary_type
Foundation = cdll.LoadLibrary(find_library('Foundation')) Foundation = cdll.LoadLibrary(find_library("Foundation"))
CoreServices = cdll.LoadLibrary(find_library('CoreServices')) CoreServices = cdll.LoadLibrary(find_library("CoreServices"))
GetMacOSStatusCommentString = Foundation.GetMacOSStatusCommentString GetMacOSStatusCommentString = Foundation.GetMacOSStatusCommentString
GetMacOSStatusCommentString.restype = c_char_p GetMacOSStatusCommentString.restype = c_char_p
@@ -28,21 +28,29 @@ kFSFileOperationSkipSourcePermissionErrors = 0x02
kFSFileOperationDoNotMoveAcrossVolumes = 0x04 kFSFileOperationDoNotMoveAcrossVolumes = 0x04
kFSFileOperationSkipPreflight = 0x08 kFSFileOperationSkipPreflight = 0x08
class FSRef(Structure): class FSRef(Structure):
_fields_ = [('hidden', c_char * 80)] _fields_ = [("hidden", c_char * 80)]
def check_op_result(op_result): def check_op_result(op_result):
if op_result: if op_result:
msg = GetMacOSStatusCommentString(op_result).decode('utf-8') msg = GetMacOSStatusCommentString(op_result).decode("utf-8")
raise OSError(msg) raise OSError(msg)
def send2trash(path):
if not isinstance(path, binary_type): def send2trash(paths):
path = path.encode('utf-8') if not isinstance(paths, list):
fp = FSRef() paths = [paths]
opts = kFSPathMakeRefDoNotFollowLeafSymlink paths = [
op_result = FSPathMakeRefWithOptions(path, opts, byref(fp), None) path.encode("utf-8") if not isinstance(path, binary_type) else path
check_op_result(op_result) for path in paths
opts = kFSFileOperationDefaultOptions ]
op_result = FSMoveObjectToTrashSync(byref(fp), None, opts) for path in paths:
check_op_result(op_result) 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 from __future__ import unicode_literals
import errno
import sys import sys
import os import os
import os.path as op import os.path as op
from datetime import datetime from datetime import datetime
import stat import stat
try: try:
from urllib.parse import quote from urllib.parse import quote
except ImportError: except ImportError:
@@ -28,33 +30,38 @@ except ImportError:
from urllib import quote from urllib import quote
from .compat import text_type, environb from .compat import text_type, environb
from .exceptions import TrashPermissionError
try: try:
fsencode = os.fsencode # Python 3 fsencode = os.fsencode # Python 3
fsdecode = os.fsdecode fsdecode = os.fsdecode
except AttributeError: except AttributeError:
def fsencode(u): # Python 2
def fsencode(u): # Python 2
return u.encode(sys.getfilesystemencoding()) return u.encode(sys.getfilesystemencoding())
def fsdecode(b): def fsdecode(b):
return b.decode(sys.getfilesystemencoding()) return b.decode(sys.getfilesystemencoding())
# The Python 3 versions are a bit smarter, handling surrogate escapes, # The Python 3 versions are a bit smarter, handling surrogate escapes,
# but these should work in most cases. # but these should work in most cases.
FILES_DIR = b'files' FILES_DIR = b"files"
INFO_DIR = b'info' INFO_DIR = b"info"
INFO_SUFFIX = b'.trashinfo' INFO_SUFFIX = b".trashinfo"
# Default of ~/.local/share [3] # Default of ~/.local/share [3]
XDG_DATA_HOME = op.expanduser(environb.get(b'XDG_DATA_HOME', b'~/.local/share')) XDG_DATA_HOME = op.expanduser(environb.get(b"XDG_DATA_HOME", b"~/.local/share"))
HOMETRASH_B = op.join(XDG_DATA_HOME, b'Trash') HOMETRASH_B = op.join(XDG_DATA_HOME, b"Trash")
HOMETRASH = fsdecode(HOMETRASH_B) HOMETRASH = fsdecode(HOMETRASH_B)
uid = os.getuid() uid = os.getuid()
TOPDIR_TRASH = b'.Trash' TOPDIR_TRASH = b".Trash"
TOPDIR_FALLBACK = b'.Trash-' + text_type(uid).encode('ascii') TOPDIR_FALLBACK = b".Trash-" + text_type(uid).encode("ascii")
def is_parent(parent, path): 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): if isinstance(path, text_type):
path = fsencode(path) path = fsencode(path)
parent = op.realpath(parent) parent = op.realpath(parent)
@@ -62,9 +69,11 @@ def is_parent(parent, path):
parent = fsencode(parent) parent = fsencode(parent)
return path.startswith(parent) return path.startswith(parent)
def format_date(date): def format_date(date):
return date.strftime("%Y-%m-%dT%H:%M:%S") return date.strftime("%Y-%m-%dT%H:%M:%S")
def info_for(src, topdir): def info_for(src, topdir):
# ...it MUST not include a ".." directory, and for files not "under" that # ...it MUST not include a ".." directory, and for files not "under" that
# directory, absolute pathnames must be used. [2] # directory, absolute pathnames must be used. [2]
@@ -73,16 +82,18 @@ def info_for(src, topdir):
else: else:
src = op.relpath(src, topdir) src = op.relpath(src, topdir)
info = "[Trash Info]\n" info = "[Trash Info]\n"
info += "Path=" + quote(src) + "\n" info += "Path=" + quote(src) + "\n"
info += "DeletionDate=" + format_date(datetime.now()) + "\n" info += "DeletionDate=" + format_date(datetime.now()) + "\n"
return info return info
def check_create(dir): def check_create(dir):
# use 0700 for paths [3] # use 0700 for paths [3]
if not op.exists(dir): if not op.exists(dir):
os.makedirs(dir, 0o700) os.makedirs(dir, 0o700)
def trash_move(src, dst, topdir=None): def trash_move(src, dst, topdir=None):
filename = op.basename(src) filename = op.basename(src)
filespath = op.join(dst, FILES_DIR) filespath = op.join(dst, FILES_DIR)
@@ -91,26 +102,30 @@ def trash_move(src, dst, topdir=None):
counter = 0 counter = 0
destname = filename 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 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(filespath)
check_create(infopath) check_create(infopath)
os.rename(src, op.join(filespath, destname)) 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.write(info_for(src, topdir))
f.close() f.close()
def find_mount_point(path): def find_mount_point(path):
# Even if something's wrong, "/" is a mount point, so the loop will exit. # Even if something's wrong, "/" is a mount point, so the loop will exit.
# Use realpath in case it's a symlink # 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): while not op.ismount(path):
path = op.split(path)[0] path = op.split(path)[0]
return path return path
def find_ext_volume_global_trash(volume_root): def find_ext_volume_global_trash(volume_root):
# from [2] Trash directories (1) check for a .Trash dir with the right # from [2] Trash directories (1) check for a .Trash dir with the right
# permissions set. # 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): if not op.isdir(trash_dir) or op.islink(trash_dir) or not (mode & stat.S_ISVTX):
return None 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: try:
check_create(trash_dir) check_create(trash_dir)
except OSError: except OSError:
return None return None
return trash_dir return trash_dir
def find_ext_volume_fallback_trash(volume_root): def find_ext_volume_fallback_trash(volume_root):
# from [2] Trash directories (1) create a .Trash-$uid dir. # from [2] Trash directories (1) create a .Trash-$uid dir.
trash_dir = op.join(volume_root, TOPDIR_FALLBACK) trash_dir = op.join(volume_root, TOPDIR_FALLBACK)
# Try to make the directory, if we can't the OSError exception will escape # Try to make the directory, if we lack permission, raise TrashPermissionError
# be thrown out of send2trash. try:
check_create(trash_dir) check_create(trash_dir)
except OSError as e:
if e.errno == errno.EACCES:
raise TrashPermissionError(e.filename)
raise
return trash_dir return trash_dir
def find_ext_volume_trash(volume_root): def find_ext_volume_trash(volume_root):
trash_dir = find_ext_volume_global_trash(volume_root) trash_dir = find_ext_volume_global_trash(volume_root)
if trash_dir is None: if trash_dir is None:
trash_dir = find_ext_volume_fallback_trash(volume_root) trash_dir = find_ext_volume_fallback_trash(volume_root)
return trash_dir return trash_dir
# Pull this out so it's easy to stub (to avoid stubbing lstat itself) # Pull this out so it's easy to stub (to avoid stubbing lstat itself)
def get_dev(path): def get_dev(path):
return os.lstat(path).st_dev 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): def send2trash(paths):
raise OSError("File not found: %s" % path) if not isinstance(paths, list):
# ...should check whether the user has the necessary permissions to delete paths = [paths]
# it, before starting the trashing operation itself. [2] for path in paths:
if not os.access(path_b, os.W_OK): if isinstance(path, text_type):
raise OSError("Permission denied: %s" % path) path_b = fsencode(path)
# if the file to be trashed is on the same device as HOMETRASH we elif isinstance(path, bytes):
# want to move it there. path_b = path
path_dev = get_dev(path_b) 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 if not op.exists(path_b):
# home directory, and these paths will be created further on if needed. raise OSError("File not found: %s" % path)
trash_dev = get_dev(op.expanduser(b'~')) # ...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: # If XDG_DATA_HOME or HOMETRASH do not yet exist we need to stat the
topdir = XDG_DATA_HOME # home directory, and these paths will be created further on if needed.
dest_trash = HOMETRASH_B trash_dev = get_dev(op.expanduser(b"~"))
else:
topdir = find_mount_point(path_b) if path_dev == trash_dev:
trash_dev = get_dev(topdir) topdir = XDG_DATA_HOME
if trash_dev != path_dev: dest_trash = HOMETRASH_B
raise OSError("Couldn't find mount point for %s" % path) else:
dest_trash = find_ext_volume_trash(topdir) topdir = find_mount_point(path_b)
trash_move(path_b, dest_trash, topdir) 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 # http://www.hardcoded.net/licenses/bsd_license
from __future__ import unicode_literals from __future__ import unicode_literals
from platform import version
from ctypes import (windll, Structure, byref, c_uint, # if windows is vista or newer and pywin32 is available use IFileOperation
create_unicode_buffer, sizeof, addressof) if int(version().split(".", 1)[0]) >= 6:
from ctypes.wintypes import HWND, UINT, LPCWSTR, BOOL try:
import os.path as op # Attempt to use pywin32 to use IFileOperation
from .plat_win_modern import send2trash
from .compat import text_type except ImportError:
# use SHFileOperation as fallback
shell32 = windll.shell32 from .plat_win_legacy import send2trash
SHFileOperationW = shell32.SHFileOperationW else:
# use SHFileOperation as fallback
class SHFILEOPSTRUCTW(Structure): from .plat_win_legacy import send2trash # noqa: F401
_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)

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 from setuptools import setup
CLASSIFIERS = [ CLASSIFIERS = [
'Development Status :: 5 - Production/Stable', "Development Status :: 5 - Production/Stable",
'Intended Audience :: Developers', "Intended Audience :: Developers",
'License :: OSI Approved :: BSD License', "License :: OSI Approved :: BSD License",
'Operating System :: MacOS :: MacOS X', "Operating System :: MacOS :: MacOS X",
'Operating System :: Microsoft :: Windows', "Operating System :: Microsoft :: Windows",
'Operating System :: POSIX', "Operating System :: POSIX",
'Programming Language :: Python :: 2.7', "Programming Language :: Python :: 2.7",
'Programming Language :: Python :: 3', "Programming Language :: Python :: 3",
'Programming Language :: Python :: 3.4', "Programming Language :: Python :: 3.4",
'Programming Language :: Python :: 3.5', "Programming Language :: Python :: 3.5",
'Programming Language :: Python :: 3.6', "Programming Language :: Python :: 3.6",
'Topic :: Desktop Environment :: File Managers', "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( setup(
name='Send2Trash', name="Send2Trash",
version='1.4.2', version="1.6.0b1",
author='Virgil Dupras', author="Andrew Senetar",
author_email='hsoft@hardcoded.net', author_email="arsenetar@voltaicideas.net",
packages=['send2trash'], packages=["send2trash"],
scripts=[], scripts=[],
test_suite='tests', test_suite="tests",
url='https://github.com/hsoft/send2trash', url="https://github.com/arsenetar/send2trash",
license='BSD License', license="BSD License",
description='Send file to trash natively under Mac OS X, Windows and Linux.', description="Send file to trash natively under Mac OS X, Windows and Linux.",
long_description=LONG_DESCRIPTION, long_description=LONG_DESCRIPTION,
classifiers=CLASSIFIERS, 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 import send2trash.plat_other
from send2trash.plat_other import send2trash as s2t from send2trash.plat_other import send2trash as s2t
from send2trash.compat import PY3 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 from tempfile import mkdtemp, NamedTemporaryFile, mktemp
import shutil import shutil
import stat import stat
import sys import sys
# Could still use cleaning up. But no longer relies on ramfs. # Could still use cleaning up. But no longer relies on ramfs.
HOMETRASH = send2trash.plat_other.HOMETRASH HOMETRASH = send2trash.plat_other.HOMETRASH
def touch(path): def touch(path):
with open(path, 'a'): with open(path, "a"):
os.utime(path, None) os.utime(path, None)
class TestHomeTrash(unittest.TestCase): class TestHomeTrash(unittest.TestCase):
def setUp(self): def setUp(self):
self.file = NamedTemporaryFile( self.file = NamedTemporaryFile(
dir=op.expanduser("~"), prefix='send2trash_test', delete=False) dir=op.expanduser("~"), prefix="send2trash_test", delete=False
)
def test_trash(self): def test_trash(self):
s2t(self.file.name) s2t(self.file.name)
@@ -30,8 +39,35 @@ class TestHomeTrash(unittest.TestCase):
def tearDown(self): def tearDown(self):
name = op.basename(self.file.name) name = op.basename(self.file.name)
os.remove(op.join(HOMETRASH, 'files', name)) os.remove(op.join(HOMETRASH, "files", name))
os.remove(op.join(HOMETRASH, 'info', name+'.trashinfo')) 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(): def _filesys_enc():
@@ -39,11 +75,12 @@ def _filesys_enc():
# Get canonical name of codec # Get canonical name of codec
return codecs.lookup(enc).name return codecs.lookup(enc).name
@unittest.skipIf(_filesys_enc() == 'ascii', 'ASCII filesystem')
@unittest.skipIf(_filesys_enc() == "ascii", "ASCII filesystem")
class TestUnicodeTrash(unittest.TestCase): class TestUnicodeTrash(unittest.TestCase):
def setUp(self): def setUp(self):
self.name = u'send2trash_tést1' self.name = u"send2trash_tést1"
self.file = op.join(op.expanduser(b'~'), self.name.encode('utf-8')) self.file = op.join(op.expanduser(b"~"), self.name.encode("utf-8"))
touch(self.file) touch(self.file)
def test_trash_bytes(self): def test_trash_bytes(self):
@@ -58,10 +95,11 @@ class TestUnicodeTrash(unittest.TestCase):
if op.exists(self.file): if op.exists(self.file):
os.remove(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): if op.exists(trash_file):
os.remove(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. # 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): class TestExtVol(unittest.TestCase):
def setUp(self): def setUp(self):
self.trashTopdir = mkdtemp(prefix='s2t') self.trashTopdir = mkdtemp(prefix="s2t")
if PY3: if PY3:
trashTopdir_b = os.fsencode(self.trashTopdir) trashTopdir_b = os.fsencode(self.trashTopdir)
else: else:
trashTopdir_b = self.trashTopdir trashTopdir_b = self.trashTopdir
self.fileName = 'test.txt' self.fileName = "test.txt"
self.filePath = op.join(self.trashTopdir, self.fileName) self.filePath = op.join(self.trashTopdir, self.fileName)
touch(self.filePath) touch(self.filePath)
self.old_ismount = old_ismount = op.ismount self.old_ismount = old_ismount = op.ismount
self.old_getdev = send2trash.plat_other.get_dev self.old_getdev = send2trash.plat_other.get_dev
def s_getdev(path): def s_getdev(path):
from send2trash.plat_other import is_parent from send2trash.plat_other import is_parent
st = os.lstat(path) st = os.lstat(path)
if is_parent(self.trashTopdir, path): if is_parent(self.trashTopdir, path):
return 'dev' return "dev"
return st.st_dev return st.st_dev
def s_ismount(path): 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 True
return old_ismount(path) return old_ismount(path)
@@ -102,23 +146,40 @@ class TestExtVol(unittest.TestCase):
send2trash.plat_other.os.path.ismount = self.old_ismount send2trash.plat_other.os.path.ismount = self.old_ismount
shutil.rmtree(self.trashTopdir) shutil.rmtree(self.trashTopdir)
class TestTopdirTrash(TestExtVol): class TestTopdirTrash(TestExtVol):
def setUp(self): def setUp(self):
TestExtVol.setUp(self) TestExtVol.setUp(self)
# Create a .Trash dir w/ a sticky bit # Create a .Trash dir w/ a sticky bit
self.trashDir = op.join(self.trashTopdir, '.Trash') self.trashDir = op.join(self.trashTopdir, ".Trash")
os.mkdir(self.trashDir, 0o777|stat.S_ISVTX) os.mkdir(self.trashDir, 0o777 | stat.S_ISVTX)
def test_trash(self): def test_trash(self):
s2t(self.filePath) s2t(self.filePath)
self.assertFalse(op.exists(self.filePath)) self.assertFalse(op.exists(self.filePath))
self.assertTrue(op.exists(op.join(self.trashDir, str(os.getuid()), 'files', self.fileName))) self.assertTrue(
self.assertTrue(op.exists(op.join(self.trashDir, str(os.getuid()), 'info', self.fileName + '.trashinfo'))) 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, # info relative path (if another test is added, with the same fileName/Path,
# then it gets renamed etc.) # then it gets renamed etc.)
cfg = ConfigParser() cfg = ConfigParser()
cfg.read(op.join(self.trashDir, str(os.getuid()), 'info', self.fileName + '.trashinfo')) cfg.read(
self.assertEqual(self.fileName, cfg.get('Trash Info', 'Path', raw=True)) 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 # Test .Trash-UID
class TestTopdirTrashFallback(TestExtVol): class TestTopdirTrashFallback(TestExtVol):
@@ -126,13 +187,23 @@ class TestTopdirTrashFallback(TestExtVol):
touch(self.filePath) touch(self.filePath)
s2t(self.filePath) s2t(self.filePath)
self.assertFalse(op.exists(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 # Test failure
class TestTopdirFailure(TestExtVol): class TestTopdirFailure(TestExtVol):
def setUp(self): def setUp(self):
TestExtVol.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): def test_trash(self):
with self.assertRaises(OSError): with self.assertRaises(OSError):
@@ -140,9 +211,10 @@ class TestTopdirFailure(TestExtVol):
self.assertTrue(op.exists(self.filePath)) self.assertTrue(op.exists(self.filePath))
def tearDown(self): def tearDown(self):
os.chmod(self.trashTopdir, 0o700) # writable to allow deletion os.chmod(self.trashTopdir, 0o700) # writable to allow deletion
TestExtVol.tearDown(self) TestExtVol.tearDown(self)
# Make sure it will find the mount point properly for a file in a symlinked path # Make sure it will find the mount point properly for a file in a symlinked path
class TestSymlink(TestExtVol): class TestSymlink(TestExtVol):
def setUp(self): def setUp(self):
@@ -150,21 +222,31 @@ class TestSymlink(TestExtVol):
# Use mktemp (race conditioney but no symlink equivalent) # Use mktemp (race conditioney but no symlink equivalent)
# Since is_parent uses realpath(), and our getdev uses is_parent, # Since is_parent uses realpath(), and our getdev uses is_parent,
# this should work # 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) os.mkdir(op.join(self.trashTopdir, "subdir"), 0o700)
self.filePath = op.join(self.trashTopdir, 'subdir', self.fileName) self.filePath = op.join(self.trashTopdir, "subdir", self.fileName)
touch(self.filePath) 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): def test_trash(self):
s2t(op.join(self.slDir, self.fileName)) s2t(op.join(self.slDir, self.fileName))
self.assertFalse(op.exists(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,
)
)
)
def tearDown(self): def tearDown(self):
os.remove(self.slDir) os.remove(self.slDir)
TestExtVol.tearDown(self) TestExtVol.tearDown(self)
if __name__ == '__main__':
if __name__ == "__main__":
unittest.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] [tox]
envlist = py27,py34,py35,py36 envlist = py27,py34,py35,py36,py3-win
skip_missing_interpreters = True skip_missing_interpreters = True
[testenv] [testenv]
platform = linux
commands = 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] [testenv:py27]
deps = deps =
configparser configparser
[flake8]
exclude = .tox,env,build
max-line-length = 120
ignore = E731,E203,E501,W503