mirror of
https://github.com/arsenetar/send2trash.git
synced 2026-01-25 16:11:39 +00:00
Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
23ce7b8c16
|
|||
|
9b0d5796c1
|
|||
|
c8bcaea1e8
|
|||
| 530e9b4bc6 | |||
|
10c7693d11
|
|||
|
356509120b
|
|||
|
f9fcdb8d8c
|
|||
|
af0c1ba704
|
|||
|
37be84d46e
|
|||
|
9f76fbf036
|
|||
|
a324923ffa
|
|||
|
dbdcce8b04
|
|||
|
054d56c564
|
|||
|
33ed07811b
|
|||
| 5d3835735e | |||
|
|
741c7ad51f | ||
| 2eb3242cd9 | |||
| 60bcb2c834 | |||
| c411f4eae4 | |||
| f64c69f905 | |||
|
00dfe77e40
|
|||
| 16a7115ff1 | |||
| ec73b44c43 | |||
|
|
f62b4f1ffd | ||
|
|
38ae2b63d2 | ||
|
|
cd8d9fb95e | ||
|
|
20bbab0b4c | ||
|
49bc438546
|
|||
|
2e9fa38f56
|
|||
|
1e099724c5
|
|||
|
6ac20bc4f6
|
|||
|
e3d2be3243
|
|||
| d078554052 | |||
|
|
9ede898c3e | ||
|
|
66afce7252 | ||
|
|
8f684a9c8b | ||
|
|
1c32d471f2 | ||
|
|
74352462f5 | ||
|
|
0d7b4b4ad9 | ||
|
|
1dded4f572 | ||
|
|
020d05979d | ||
|
|
6b0bd46036 | ||
|
|
f6897609ba |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
/.tox
|
/.tox
|
||||||
__pycache__
|
__pycache__
|
||||||
|
/env
|
||||||
37
.travis.yml
37
.travis.yml
@@ -1,10 +1,33 @@
|
|||||||
language: python
|
language: python
|
||||||
python:
|
matrix:
|
||||||
- "2.7"
|
include:
|
||||||
- "3.4"
|
- os: windows
|
||||||
- "3.5"
|
language: sh
|
||||||
- "3.6"
|
python: "3.8"
|
||||||
|
env: "PATH=/c/Python38:/c/Python38/Scripts:$PATH"
|
||||||
|
# Perform the manual steps on windows to install python3
|
||||||
|
before_install:
|
||||||
|
- choco install python --version=3.8.6
|
||||||
|
- python -m pip install --upgrade pip
|
||||||
|
before_script:
|
||||||
|
- export TOXENV=py38
|
||||||
|
- python: "2.7"
|
||||||
|
- python: "3.4"
|
||||||
|
- python: "3.5"
|
||||||
|
- python: "3.6"
|
||||||
|
- python: "3.7"
|
||||||
|
- python: "3.8"
|
||||||
|
- python: "3.9"
|
||||||
|
- python: "nightly" # 3.10
|
||||||
|
before_script:
|
||||||
|
- export TOXENV=py310
|
||||||
|
- python: "2.7"
|
||||||
|
arch: ppc64le
|
||||||
|
- python: "3.6"
|
||||||
|
arch: ppc64le
|
||||||
install:
|
install:
|
||||||
- "pip install tox"
|
- python -m 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 .)"
|
- python -m tox
|
||||||
|
|||||||
25
CHANGES.rst
25
CHANGES.rst
@@ -1,6 +1,31 @@
|
|||||||
Changes
|
Changes
|
||||||
=======
|
=======
|
||||||
|
|
||||||
|
Version 1.7.0a -- 2020/04/20
|
||||||
|
----------------------------
|
||||||
|
* Add console_script entry point (#50)
|
||||||
|
* Increased python CI versions (#52, #54)
|
||||||
|
* Fix minor issue in setup.py (#53)
|
||||||
|
* Fix issue with windows tests importing modules on non-windows (#55)
|
||||||
|
* Unit test cleanups, rewrites, and flake8 cleanups
|
||||||
|
* Windows: Fix legacy windows platform for multi-byte unicode and add tests
|
||||||
|
* macOS: Add alternative pyobjc version to potentially improve compatibility (#51)
|
||||||
|
|
||||||
|
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
|
||||||
---------------------------
|
---------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
include CHANGES.rst
|
include CHANGES.rst LICENSE
|
||||||
|
|||||||
30
README.rst
30
README.rst
@@ -3,23 +3,30 @@ 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
|
||||||
------------
|
------------
|
||||||
|
|
||||||
You can download it with pip::
|
You can download it with pip::
|
||||||
|
|
||||||
pip install Send2Trash
|
python -m pip install -U 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/
|
||||||
|
|||||||
105
send2trash/IFileOperationProgressSink.py
Normal file
105
send2trash/IFileOperationProgressSink.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Sample implementation of IFileOperationProgressSink that just prints
|
||||||
|
# some basic info
|
||||||
|
|
||||||
|
import pythoncom
|
||||||
|
from win32com.shell import shell, shellcon
|
||||||
|
from win32com.server.policy import DesignatedWrapPolicy
|
||||||
|
|
||||||
|
|
||||||
|
class FileOperationProgressSink(DesignatedWrapPolicy):
|
||||||
|
_com_interfaces_ = [shell.IID_IFileOperationProgressSink]
|
||||||
|
_public_methods_ = [
|
||||||
|
"StartOperations",
|
||||||
|
"FinishOperations",
|
||||||
|
"PreRenameItem",
|
||||||
|
"PostRenameItem",
|
||||||
|
"PreMoveItem",
|
||||||
|
"PostMoveItem",
|
||||||
|
"PreCopyItem",
|
||||||
|
"PostCopyItem",
|
||||||
|
"PreDeleteItem",
|
||||||
|
"PostDeleteItem",
|
||||||
|
"PreNewItem",
|
||||||
|
"PostNewItem",
|
||||||
|
"UpdateProgress",
|
||||||
|
"ResetTimer",
|
||||||
|
"PauseTimer",
|
||||||
|
"ResumeTimer",
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._wrap_(self)
|
||||||
|
self.newItem = None
|
||||||
|
|
||||||
|
def PreDeleteItem(self, flags, item):
|
||||||
|
# Can detect cases where to stop via flags and condition below, however the operation
|
||||||
|
# does not actual stop, we can resort to raising an exception as that does stop things
|
||||||
|
# but that may need some additional considerations before implementing.
|
||||||
|
return (
|
||||||
|
0 if flags & shellcon.TSF_DELETE_RECYCLE_IF_POSSIBLE else 0x80004005
|
||||||
|
) # S_OK, or E_FAIL
|
||||||
|
|
||||||
|
def PostDeleteItem(self, flags, item, hrDelete, newlyCreated):
|
||||||
|
if newlyCreated:
|
||||||
|
self.newItem = newlyCreated.GetDisplayName(shellcon.SHGDN_FORPARSING)
|
||||||
|
|
||||||
|
def StartOperations(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def FinishOperations(self, Result):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def PreRenameItem(self, Flags, Item, NewName):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def PostRenameItem(self, Flags, Item, NewName, hrRename, NewlyCreated):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def PreMoveItem(self, Flags, Item, DestinationFolder, NewName):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def PostMoveItem(
|
||||||
|
self, Flags, Item, DestinationFolder, NewName, hrMove, NewlyCreated
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def PreCopyItem(self, Flags, Item, DestinationFolder, NewName):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def PostCopyItem(
|
||||||
|
self, Flags, Item, DestinationFolder, NewName, hrCopy, NewlyCreated
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def PreNewItem(self, Flags, DestinationFolder, NewName):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def PostNewItem(
|
||||||
|
self,
|
||||||
|
Flags,
|
||||||
|
DestinationFolder,
|
||||||
|
NewName,
|
||||||
|
TemplateName,
|
||||||
|
FileAttributes,
|
||||||
|
hrNew,
|
||||||
|
NewItem,
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def UpdateProgress(self, WorkTotal, WorkSoFar):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def ResetTimer(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def PauseTimer(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def ResumeTimer(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def CreateSink():
|
||||||
|
return pythoncom.WrapObject(
|
||||||
|
FileOperationProgressSink(), shell.IID_IFileOperationProgressSink
|
||||||
|
)
|
||||||
@@ -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
33
send2trash/__main__.py
Normal 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()
|
||||||
@@ -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
26
send2trash/exceptions.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import errno
|
||||||
|
from .compat import PY3
|
||||||
|
|
||||||
|
if PY3:
|
||||||
|
_permission_error = PermissionError # noqa: F821
|
||||||
|
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)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -4,45 +4,17 @@
|
|||||||
# 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
|
||||||
|
|
||||||
from __future__ import unicode_literals
|
from platform import mac_ver
|
||||||
|
from sys import version_info
|
||||||
|
|
||||||
from ctypes import cdll, byref, Structure, c_char, c_char_p
|
# NOTE: version of pyobjc only supports python >= 3.6 and 10.9+
|
||||||
from ctypes.util import find_library
|
macos_ver = tuple(int(part) for part in mac_ver()[0].split("."))
|
||||||
|
if version_info >= (3, 6) and macos_ver >= (10, 9):
|
||||||
from .compat import binary_type
|
try:
|
||||||
|
from .plat_osx_pyobjc import send2trash
|
||||||
Foundation = cdll.LoadLibrary(find_library('Foundation'))
|
except ImportError:
|
||||||
CoreServices = cdll.LoadLibrary(find_library('CoreServices'))
|
# Try to fall back to ctypes version, although likely problematic still
|
||||||
|
from .plat_osx_ctypes import send2trash
|
||||||
GetMacOSStatusCommentString = Foundation.GetMacOSStatusCommentString
|
else:
|
||||||
GetMacOSStatusCommentString.restype = c_char_p
|
# Just use the old version otherwise
|
||||||
FSPathMakeRefWithOptions = CoreServices.FSPathMakeRefWithOptions
|
from .plat_osx_ctypes import send2trash # noqa: F401
|
||||||
FSMoveObjectToTrashSync = CoreServices.FSMoveObjectToTrashSync
|
|
||||||
|
|
||||||
kFSPathMakeRefDefaultOptions = 0
|
|
||||||
kFSPathMakeRefDoNotFollowLeafSymlink = 0x01
|
|
||||||
|
|
||||||
kFSFileOperationDefaultOptions = 0
|
|
||||||
kFSFileOperationOverwrite = 0x01
|
|
||||||
kFSFileOperationSkipSourcePermissionErrors = 0x02
|
|
||||||
kFSFileOperationDoNotMoveAcrossVolumes = 0x04
|
|
||||||
kFSFileOperationSkipPreflight = 0x08
|
|
||||||
|
|
||||||
class FSRef(Structure):
|
|
||||||
_fields_ = [('hidden', c_char * 80)]
|
|
||||||
|
|
||||||
def check_op_result(op_result):
|
|
||||||
if op_result:
|
|
||||||
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)
|
|
||||||
|
|||||||
56
send2trash/plat_osx_ctypes.py
Normal file
56
send2trash/plat_osx_ctypes.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# Copyright 2017 Virgil Dupras
|
||||||
|
|
||||||
|
# This software is licensed under the "BSD" License as described in the "LICENSE" file,
|
||||||
|
# which should be included with this package. The terms are also available at
|
||||||
|
# http://www.hardcoded.net/licenses/bsd_license
|
||||||
|
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from ctypes import cdll, byref, Structure, c_char, c_char_p
|
||||||
|
from ctypes.util import find_library
|
||||||
|
|
||||||
|
from .compat import binary_type
|
||||||
|
|
||||||
|
Foundation = cdll.LoadLibrary(find_library("Foundation"))
|
||||||
|
CoreServices = cdll.LoadLibrary(find_library("CoreServices"))
|
||||||
|
|
||||||
|
GetMacOSStatusCommentString = Foundation.GetMacOSStatusCommentString
|
||||||
|
GetMacOSStatusCommentString.restype = c_char_p
|
||||||
|
FSPathMakeRefWithOptions = CoreServices.FSPathMakeRefWithOptions
|
||||||
|
FSMoveObjectToTrashSync = CoreServices.FSMoveObjectToTrashSync
|
||||||
|
|
||||||
|
kFSPathMakeRefDefaultOptions = 0
|
||||||
|
kFSPathMakeRefDoNotFollowLeafSymlink = 0x01
|
||||||
|
|
||||||
|
kFSFileOperationDefaultOptions = 0
|
||||||
|
kFSFileOperationOverwrite = 0x01
|
||||||
|
kFSFileOperationSkipSourcePermissionErrors = 0x02
|
||||||
|
kFSFileOperationDoNotMoveAcrossVolumes = 0x04
|
||||||
|
kFSFileOperationSkipPreflight = 0x08
|
||||||
|
|
||||||
|
|
||||||
|
class FSRef(Structure):
|
||||||
|
_fields_ = [("hidden", c_char * 80)]
|
||||||
|
|
||||||
|
|
||||||
|
def check_op_result(op_result):
|
||||||
|
if op_result:
|
||||||
|
msg = GetMacOSStatusCommentString(op_result).decode("utf-8")
|
||||||
|
raise OSError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
29
send2trash/plat_osx_pyobjc.py
Normal file
29
send2trash/plat_osx_pyobjc.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Copyright 2017 Virgil Dupras
|
||||||
|
|
||||||
|
# This software is licensed under the "BSD" License as described in the "LICENSE" file,
|
||||||
|
# which should be included with this package. The terms are also available at
|
||||||
|
# http://www.hardcoded.net/licenses/bsd_license
|
||||||
|
|
||||||
|
from Foundation import NSFileManager, NSURL
|
||||||
|
from .compat import text_type
|
||||||
|
|
||||||
|
|
||||||
|
def check_op_result(op_result):
|
||||||
|
# First value will be false on failure
|
||||||
|
if not op_result[0]:
|
||||||
|
# Error is in third value, localized failure reason matchs ctypes version
|
||||||
|
raise OSError(op_result[2].localizedFailureReason())
|
||||||
|
|
||||||
|
|
||||||
|
def send2trash(paths):
|
||||||
|
if not isinstance(paths, list):
|
||||||
|
paths = [paths]
|
||||||
|
paths = [
|
||||||
|
path.decode("utf-8") if not isinstance(path, text_type) else path
|
||||||
|
for path in paths
|
||||||
|
]
|
||||||
|
for path in paths:
|
||||||
|
file_url = NSURL.fileURLWithPath_(path)
|
||||||
|
fm = NSFileManager.defaultManager()
|
||||||
|
op_result = fm.trashItemAtURL_resultingItemURL_error_(file_url, None, None)
|
||||||
|
check_op_result(op_result)
|
||||||
@@ -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,29 @@ 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)
|
||||||
|
|
||||||
|
with open(op.join(infopath, destname + INFO_SUFFIX), "w") as f:
|
||||||
|
f.write(info_for(src, topdir))
|
||||||
os.rename(src, op.join(filespath, destname))
|
os.rename(src, op.join(filespath, destname))
|
||||||
f = open(op.join(infopath, destname + INFO_SUFFIX), 'w')
|
|
||||||
f.write(info_for(src, topdir))
|
|
||||||
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 +138,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)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
111
send2trash/plat_win_legacy.py
Normal file
111
send2trash/plat_win_legacy.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# 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]
|
||||||
|
fileop = SHFILEOPSTRUCTW()
|
||||||
|
fileop.hwnd = 0
|
||||||
|
fileop.wFunc = FO_DELETE
|
||||||
|
# FIX: https://github.com/hsoft/send2trash/issues/17
|
||||||
|
# Starting in python 3.6.3 it is no longer possible to use:
|
||||||
|
# LPCWSTR(path + '\0') directly as embedded null characters are no longer
|
||||||
|
# allowed in strings
|
||||||
|
# Workaround
|
||||||
|
# - create buffer of c_wchar[] (LPCWSTR is based on this type)
|
||||||
|
# - buffer is two c_wchar characters longer (double null terminator)
|
||||||
|
# - cast the address of the buffer to a LPCWSTR
|
||||||
|
# NOTE: based on how python allocates memory for these types they should
|
||||||
|
# always be zero, if this is ever not true we can go back to explicitly
|
||||||
|
# setting the last two characters to null using buffer[index] = '\0'.
|
||||||
|
# Additional note on another issue here, unicode_buffer expects length in
|
||||||
|
# bytes essentially, so having multi-byte characters causes issues if just
|
||||||
|
# passing pythons string length. Instead of dealing with this difference we
|
||||||
|
# just create a buffer then a new one with an extra null. Since the non-length
|
||||||
|
# specified version apparently stops after the first null, join with a space first.
|
||||||
|
buffer = create_unicode_buffer(" ".join(paths))
|
||||||
|
# convert to a single string of null terminated paths
|
||||||
|
path_string = "\0".join(paths)
|
||||||
|
buffer = create_unicode_buffer(path_string, len(buffer) + 1)
|
||||||
|
fileop.pFrom = LPCWSTR(addressof(buffer))
|
||||||
|
fileop.pTo = None
|
||||||
|
fileop.fFlags = FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_NOERRORUI | FOF_SILENT
|
||||||
|
fileop.fAnyOperationsAborted = 0
|
||||||
|
fileop.hNameMappings = 0
|
||||||
|
fileop.lpszProgressTitle = None
|
||||||
|
result = SHFileOperationW(byref(fileop))
|
||||||
|
if result:
|
||||||
|
raise WindowsError(result, FormatError(result), paths)
|
||||||
67
send2trash/plat_win_modern.py
Normal file
67
send2trash/plat_win_modern.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# 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
|
||||||
|
from .IFileOperationProgressSink import CreateSink
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
sink = CreateSink()
|
||||||
|
try:
|
||||||
|
for path in paths:
|
||||||
|
item = shell.SHCreateItemFromParsingName(path, None, shell.IID_IShellItem)
|
||||||
|
fileop.DeleteItem(item, sink)
|
||||||
|
result = fileop.PerformOperations()
|
||||||
|
aborted = fileop.GetAnyOperationsAborted()
|
||||||
|
# if non-zero result or aborted throw an exception
|
||||||
|
if result or aborted:
|
||||||
|
raise OSError(None, None, paths, result)
|
||||||
|
except pywintypes.com_error as error:
|
||||||
|
# convert to standard OS error, allows other code to get a
|
||||||
|
# normal errno
|
||||||
|
raise OSError(None, error.strerror, path, error.hresult)
|
||||||
52
setup.py
52
setup.py
@@ -1,33 +1,41 @@
|
|||||||
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",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Topic :: Desktop Environment :: File Managers",
|
||||||
]
|
]
|
||||||
|
|
||||||
LONG_DESCRIPTION = open('README.rst', 'rt').read() + '\n\n' + open('CHANGES.rst', 'rt').read()
|
with open("README.rst", "rt") as f1, open("CHANGES.rst", "rt") as f2:
|
||||||
|
LONG_DESCRIPTION = f1.read() + "\n\n" + f2.read()
|
||||||
|
|
||||||
setup(
|
setup(
|
||||||
name='Send2Trash',
|
name="Send2Trash",
|
||||||
version='1.4.2',
|
version="1.7.0a1",
|
||||||
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"},
|
||||||
|
entry_points={"console_scripts": ["send2trash=send2trash.__main__:main"]},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,170 +1,235 @@
|
|||||||
# encoding: utf-8
|
# encoding: utf-8
|
||||||
|
import pytest
|
||||||
import codecs
|
import codecs
|
||||||
import unittest
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
from os import path as op
|
from os import path as op
|
||||||
import send2trash.plat_other
|
|
||||||
from send2trash.plat_other import send2trash as s2t
|
|
||||||
from send2trash.compat import PY3
|
from send2trash.compat import PY3
|
||||||
from configparser import ConfigParser
|
from send2trash import TrashPermissionError
|
||||||
|
|
||||||
|
try:
|
||||||
|
from configparser import ConfigParser
|
||||||
|
except ImportError:
|
||||||
|
# py2
|
||||||
|
from ConfigParser import ConfigParser # noqa: F401
|
||||||
|
|
||||||
from tempfile import mkdtemp, NamedTemporaryFile, mktemp
|
from tempfile import mkdtemp, NamedTemporaryFile, mktemp
|
||||||
import shutil
|
import shutil
|
||||||
import stat
|
import stat
|
||||||
import sys
|
|
||||||
# Could still use cleaning up. But no longer relies on ramfs.
|
|
||||||
|
|
||||||
HOMETRASH = send2trash.plat_other.HOMETRASH
|
if sys.platform != "win32":
|
||||||
|
import send2trash.plat_other
|
||||||
|
from send2trash.plat_other import send2trash as s2t
|
||||||
|
|
||||||
|
HOMETRASH = send2trash.plat_other.HOMETRASH
|
||||||
|
else:
|
||||||
|
pytest.skip("Skipping non-windows tests", allow_module_level=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def testfile():
|
||||||
|
file = NamedTemporaryFile(
|
||||||
|
dir=op.expanduser("~"), prefix="send2trash_test", delete=False
|
||||||
|
)
|
||||||
|
file.close()
|
||||||
|
assert op.exists(file.name) is True
|
||||||
|
yield file
|
||||||
|
# Cleanup trash files on supported platforms
|
||||||
|
if sys.platform != "win32":
|
||||||
|
name = op.basename(file.name)
|
||||||
|
# Remove trash files if they exist
|
||||||
|
if op.exists(op.join(HOMETRASH, "files", name)):
|
||||||
|
os.remove(op.join(HOMETRASH, "files", name))
|
||||||
|
os.remove(op.join(HOMETRASH, "info", name + ".trashinfo"))
|
||||||
|
if op.exists(file.name):
|
||||||
|
os.remove(file.name)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def testfiles():
|
||||||
|
files = list(
|
||||||
|
map(
|
||||||
|
lambda index: NamedTemporaryFile(
|
||||||
|
dir=op.expanduser("~"),
|
||||||
|
prefix="send2trash_test{}".format(index),
|
||||||
|
delete=False,
|
||||||
|
),
|
||||||
|
range(10),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
[file.close() for file in files]
|
||||||
|
assert all([op.exists(file.name) for file in files]) is True
|
||||||
|
yield files
|
||||||
|
filenames = [op.basename(file.name) for file in 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 test_trash(testfile):
|
||||||
|
s2t(testfile.name)
|
||||||
|
assert op.exists(testfile.name) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_multitrash(testfiles):
|
||||||
|
filenames = [file.name for file in testfiles]
|
||||||
|
s2t(filenames)
|
||||||
|
assert any([op.exists(filename) for filename in filenames]) is False
|
||||||
|
|
||||||
|
|
||||||
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):
|
|
||||||
def setUp(self):
|
|
||||||
self.file = NamedTemporaryFile(
|
|
||||||
dir=op.expanduser("~"), prefix='send2trash_test', delete=False)
|
|
||||||
|
|
||||||
def test_trash(self):
|
|
||||||
s2t(self.file.name)
|
|
||||||
self.assertFalse(op.exists(self.file.name))
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
name = op.basename(self.file.name)
|
|
||||||
os.remove(op.join(HOMETRASH, 'files', name))
|
|
||||||
os.remove(op.join(HOMETRASH, 'info', name+'.trashinfo'))
|
|
||||||
|
|
||||||
|
|
||||||
def _filesys_enc():
|
def _filesys_enc():
|
||||||
enc = sys.getfilesystemencoding()
|
enc = sys.getfilesystemencoding()
|
||||||
# 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')
|
|
||||||
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'))
|
|
||||||
touch(self.file)
|
|
||||||
|
|
||||||
def test_trash_bytes(self):
|
@pytest.fixture
|
||||||
s2t(self.file)
|
def testUnicodefile():
|
||||||
assert not op.exists(self.file)
|
name = u"send2trash_tést1"
|
||||||
|
file = op.join(op.expanduser(b"~"), name.encode("utf-8"))
|
||||||
|
touch(file)
|
||||||
|
assert op.exists(file) is True
|
||||||
|
yield file
|
||||||
|
# Cleanup trash files on supported platforms
|
||||||
|
if sys.platform != "win32":
|
||||||
|
# Remove trash files if they exist
|
||||||
|
if op.exists(op.join(HOMETRASH, "files", name)):
|
||||||
|
os.remove(op.join(HOMETRASH, "files", name))
|
||||||
|
os.remove(op.join(HOMETRASH, "info", name + ".trashinfo"))
|
||||||
|
if op.exists(file):
|
||||||
|
os.remove(file)
|
||||||
|
|
||||||
def test_trash_unicode(self):
|
|
||||||
s2t(self.file.decode(sys.getfilesystemencoding()))
|
|
||||||
assert not op.exists(self.file)
|
|
||||||
|
|
||||||
def tearDown(self):
|
@pytest.mark.skipif(_filesys_enc() == "ascii", reason="Requires Unicode filesystem")
|
||||||
if op.exists(self.file):
|
def test_trash_bytes(testUnicodefile):
|
||||||
os.remove(self.file)
|
s2t(testUnicodefile)
|
||||||
|
assert not op.exists(testUnicodefile)
|
||||||
|
|
||||||
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'))
|
|
||||||
|
|
||||||
#
|
@pytest.mark.skipif(_filesys_enc() == "ascii", reason="Requires Unicode filesystem")
|
||||||
# Tests for files on some other volume than the user's home directory.
|
def test_trash_unicode(testUnicodefile):
|
||||||
#
|
s2t(testUnicodefile.decode(sys.getfilesystemencoding()))
|
||||||
# What we need to stub:
|
assert not op.exists(testUnicodefile)
|
||||||
# * plat_other.get_dev (to make sure the file will not be on the home dir dev)
|
|
||||||
# * os.path.ismount (to make our topdir look like a top dir)
|
|
||||||
#
|
class ExtVol:
|
||||||
class TestExtVol(unittest.TestCase):
|
def __init__(self, path):
|
||||||
def setUp(self):
|
self.trashTopdir = path
|
||||||
self.trashTopdir = mkdtemp(prefix='s2t')
|
|
||||||
if PY3:
|
if PY3:
|
||||||
trashTopdir_b = os.fsencode(self.trashTopdir)
|
self.trashTopdir_b = os.fsencode(self.trashTopdir)
|
||||||
else:
|
else:
|
||||||
trashTopdir_b = self.trashTopdir
|
self.trashTopdir_b = self.trashTopdir
|
||||||
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):
|
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(self.trashTopdir_b),
|
||||||
|
):
|
||||||
return True
|
return True
|
||||||
return old_ismount(path)
|
return old_ismount(path)
|
||||||
|
|
||||||
|
self.old_ismount = old_ismount = op.ismount
|
||||||
|
self.old_getdev = send2trash.plat_other.get_dev
|
||||||
send2trash.plat_other.os.path.ismount = s_ismount
|
send2trash.plat_other.os.path.ismount = s_ismount
|
||||||
send2trash.plat_other.get_dev = s_getdev
|
send2trash.plat_other.get_dev = s_getdev
|
||||||
|
|
||||||
def tearDown(self):
|
def cleanup(self):
|
||||||
send2trash.plat_other.get_dev = self.old_getdev
|
send2trash.plat_other.get_dev = self.old_getdev
|
||||||
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):
|
|
||||||
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)
|
|
||||||
|
|
||||||
def test_trash(self):
|
@pytest.fixture
|
||||||
s2t(self.filePath)
|
def testExtVol():
|
||||||
self.assertFalse(op.exists(self.filePath))
|
trashTopdir = mkdtemp(prefix="s2t")
|
||||||
self.assertTrue(op.exists(op.join(self.trashDir, str(os.getuid()), 'files', self.fileName)))
|
volume = ExtVol(trashTopdir)
|
||||||
self.assertTrue(op.exists(op.join(self.trashDir, str(os.getuid()), 'info', self.fileName + '.trashinfo')))
|
fileName = "test.txt"
|
||||||
# info relative path (if another test is added, with the same fileName/Path,
|
filePath = op.join(volume.trashTopdir, fileName)
|
||||||
# then it gets renamed etc.)
|
touch(filePath)
|
||||||
cfg = ConfigParser()
|
assert op.exists(filePath) is True
|
||||||
cfg.read(op.join(self.trashDir, str(os.getuid()), 'info', self.fileName + '.trashinfo'))
|
yield volume, fileName, filePath
|
||||||
self.assertEqual(self.fileName, cfg.get('Trash Info', 'Path', raw=True))
|
volume.cleanup()
|
||||||
|
|
||||||
# Test .Trash-UID
|
|
||||||
class TestTopdirTrashFallback(TestExtVol):
|
|
||||||
def test_trash(self):
|
|
||||||
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)))
|
|
||||||
|
|
||||||
# Test failure
|
def test_trash_topdir(testExtVol):
|
||||||
class TestTopdirFailure(TestExtVol):
|
trashDir = op.join(testExtVol[0].trashTopdir, ".Trash")
|
||||||
def setUp(self):
|
os.mkdir(trashDir, 0o777 | stat.S_ISVTX)
|
||||||
TestExtVol.setUp(self)
|
|
||||||
os.chmod(self.trashTopdir, 0o500) # not writable to induce the exception
|
|
||||||
|
|
||||||
def test_trash(self):
|
s2t(testExtVol[2])
|
||||||
with self.assertRaises(OSError):
|
assert op.exists(testExtVol[2]) is False
|
||||||
s2t(self.filePath)
|
assert (
|
||||||
self.assertTrue(op.exists(self.filePath))
|
op.exists(op.join(trashDir, str(os.getuid()), "files", testExtVol[1])) is True
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
op.exists(
|
||||||
|
op.join(trashDir, str(os.getuid()), "info", testExtVol[1] + ".trashinfo",)
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
# info relative path (if another test is added, with the same fileName/Path,
|
||||||
|
# then it gets renamed etc.)
|
||||||
|
cfg = ConfigParser()
|
||||||
|
cfg.read(op.join(trashDir, str(os.getuid()), "info", testExtVol[1] + ".trashinfo"))
|
||||||
|
assert (testExtVol[1] == cfg.get("Trash Info", "Path", raw=True)) is True
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
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
|
def test_trash_topdir_fallback(testExtVol):
|
||||||
class TestSymlink(TestExtVol):
|
s2t(testExtVol[2])
|
||||||
def setUp(self):
|
assert op.exists(testExtVol[2]) is False
|
||||||
TestExtVol.setUp(self)
|
assert (
|
||||||
# Use mktemp (race conditioney but no symlink equivalent)
|
op.exists(
|
||||||
# Since is_parent uses realpath(), and our getdev uses is_parent,
|
op.join(
|
||||||
# this should work
|
testExtVol[0].trashTopdir,
|
||||||
self.slDir = mktemp(prefix='s2t', dir=op.expanduser('~'))
|
".Trash-" + str(os.getuid()),
|
||||||
|
"files",
|
||||||
|
testExtVol[1],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
def test_trash(self):
|
def test_trash_topdir_failure(testExtVol):
|
||||||
s2t(op.join(self.slDir, self.fileName))
|
os.chmod(testExtVol[0].trashTopdir, 0o500) # not writable to induce the exception
|
||||||
self.assertFalse(op.exists(self.filePath))
|
pytest.raises(TrashPermissionError, s2t, [testExtVol[2]])
|
||||||
self.assertTrue(op.exists(op.join(self.trashTopdir, '.Trash-' + str(os.getuid()), 'files', self.fileName)))
|
os.chmod(testExtVol[0].trashTopdir, 0o700) # writable to allow deletion
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
os.remove(self.slDir)
|
|
||||||
TestExtVol.tearDown(self)
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
def test_trash_symlink(testExtVol):
|
||||||
unittest.main()
|
# Use mktemp (race conditioney but no symlink equivalent)
|
||||||
|
# Since is_parent uses realpath(), and our getdev uses is_parent,
|
||||||
|
# this should work
|
||||||
|
slDir = mktemp(prefix="s2t", dir=op.expanduser("~"))
|
||||||
|
os.mkdir(op.join(testExtVol[0].trashTopdir, "subdir"), 0o700)
|
||||||
|
filePath = op.join(testExtVol[0].trashTopdir, "subdir", testExtVol[1])
|
||||||
|
touch(filePath)
|
||||||
|
os.symlink(op.join(testExtVol[0].trashTopdir, "subdir"), slDir)
|
||||||
|
s2t(op.join(slDir, testExtVol[1]))
|
||||||
|
assert op.exists(filePath) is False
|
||||||
|
assert (
|
||||||
|
op.exists(
|
||||||
|
op.join(
|
||||||
|
testExtVol[0].trashTopdir,
|
||||||
|
".Trash-" + str(os.getuid()),
|
||||||
|
"files",
|
||||||
|
testExtVol[1],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
os.remove(slDir)
|
||||||
|
|||||||
203
tests/test_plat_win.py
Normal file
203
tests/test_plat_win.py
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# encoding: utf-8
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import pytest
|
||||||
|
from os import path as op
|
||||||
|
|
||||||
|
from send2trash import send2trash as s2t
|
||||||
|
|
||||||
|
# import the two versions as well as the "automatic" version
|
||||||
|
if sys.platform == "win32":
|
||||||
|
from send2trash.plat_win_modern import send2trash as s2t_modern
|
||||||
|
from send2trash.plat_win_legacy import send2trash as s2t_legacy
|
||||||
|
else:
|
||||||
|
pytest.skip("Skipping windows-only tests", allow_module_level=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_tree(path):
|
||||||
|
dirname = op.dirname(path)
|
||||||
|
if not op.isdir(dirname):
|
||||||
|
os.makedirs(dirname)
|
||||||
|
with open(path, "w") as writer:
|
||||||
|
writer.write("send2trash test")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def testdir(tmp_path):
|
||||||
|
dirname = "\\\\?\\" + str(tmp_path)
|
||||||
|
assert op.exists(dirname) is True
|
||||||
|
yield dirname
|
||||||
|
shutil.rmtree(dirname, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def testfile(testdir):
|
||||||
|
file = op.join(testdir, "testfile.txt")
|
||||||
|
_create_tree(file)
|
||||||
|
assert op.exists(file) is True
|
||||||
|
yield file
|
||||||
|
# Note dir will cleanup the file
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def testfiles(testdir):
|
||||||
|
files = [op.join(testdir, "testfile{}.txt".format(index)) for index in range(10)]
|
||||||
|
[_create_tree(file) for file in files]
|
||||||
|
assert all([op.exists(file) for file in files]) is True
|
||||||
|
yield files
|
||||||
|
# Note dir will cleanup the files
|
||||||
|
|
||||||
|
|
||||||
|
def _trash_folder(dir, fcn):
|
||||||
|
fcn(dir)
|
||||||
|
assert op.exists(dir) is False
|
||||||
|
|
||||||
|
|
||||||
|
def _trash_file(file, fcn):
|
||||||
|
fcn(file)
|
||||||
|
assert op.exists(file) is False
|
||||||
|
|
||||||
|
|
||||||
|
def _trash_multifile(files, fcn):
|
||||||
|
fcn(files)
|
||||||
|
assert any([op.exists(file) for file in files]) is False
|
||||||
|
|
||||||
|
|
||||||
|
def _file_not_found(dir, fcn):
|
||||||
|
file = op.join(dir, "otherfile.txt")
|
||||||
|
pytest.raises(OSError, fcn, file)
|
||||||
|
|
||||||
|
|
||||||
|
def _multi_byte_unicode(dir, fcn):
|
||||||
|
single_file = op.join(dir, "😇.txt")
|
||||||
|
_create_tree(single_file)
|
||||||
|
assert op.exists(single_file) is True
|
||||||
|
fcn(single_file)
|
||||||
|
assert op.exists(single_file) is False
|
||||||
|
files = [op.join(dir, "😇{}.txt".format(index)) for index in range(10)]
|
||||||
|
[_create_tree(file) for file in files]
|
||||||
|
assert all([op.exists(file) for file in files]) is True
|
||||||
|
fcn(files)
|
||||||
|
assert any([op.exists(file) for file in files]) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_trash_folder(testdir):
|
||||||
|
_trash_folder(testdir, s2t)
|
||||||
|
|
||||||
|
|
||||||
|
def test_trash_file(testfile):
|
||||||
|
_trash_file(testfile, s2t)
|
||||||
|
|
||||||
|
|
||||||
|
def test_trash_multifile(testfiles):
|
||||||
|
_trash_multifile(testfiles, s2t)
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_not_found(testdir):
|
||||||
|
_file_not_found(testdir, s2t)
|
||||||
|
|
||||||
|
|
||||||
|
def test_trash_folder_modern(testdir):
|
||||||
|
_trash_folder(testdir, s2t_modern)
|
||||||
|
|
||||||
|
|
||||||
|
def test_trash_file_modern(testfile):
|
||||||
|
_trash_file(testfile, s2t_modern)
|
||||||
|
|
||||||
|
|
||||||
|
def test_trash_multifile_modern(testfiles):
|
||||||
|
_trash_multifile(testfiles, s2t_modern)
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_not_found_modern(testdir):
|
||||||
|
_file_not_found(testdir, s2t_modern)
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_byte_unicode_modern(testdir):
|
||||||
|
_multi_byte_unicode(testdir, s2t_modern)
|
||||||
|
|
||||||
|
|
||||||
|
def test_trash_folder_legacy(testdir):
|
||||||
|
_trash_folder(testdir, s2t_legacy)
|
||||||
|
|
||||||
|
|
||||||
|
def test_trash_file_legacy(testfile):
|
||||||
|
_trash_file(testfile, s2t_legacy)
|
||||||
|
|
||||||
|
|
||||||
|
def test_trash_multifile_legacy(testfiles):
|
||||||
|
_trash_multifile(testfiles, s2t_legacy)
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_not_found_legacy(testdir):
|
||||||
|
_file_not_found(testdir, s2t_legacy)
|
||||||
|
|
||||||
|
|
||||||
|
def test_multi_byte_unicode_legacy(testdir):
|
||||||
|
_multi_byte_unicode(testdir, s2t_legacy)
|
||||||
|
|
||||||
|
|
||||||
|
# Long path tests
|
||||||
|
@pytest.fixture
|
||||||
|
def longdir(tmp_path):
|
||||||
|
dirname = "\\\\?\\" + str(tmp_path)
|
||||||
|
name = "A" * 100
|
||||||
|
yield op.join(dirname, name, name, name)
|
||||||
|
shutil.rmtree(dirname, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def longfile(longdir):
|
||||||
|
name = "A" * 100
|
||||||
|
path = op.join(longdir, name + "{}.txt")
|
||||||
|
file = path.format("")
|
||||||
|
_create_tree(file)
|
||||||
|
assert op.exists(file) is True
|
||||||
|
yield file
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def longfiles(longdir):
|
||||||
|
name = "A" * 100
|
||||||
|
path = op.join(longdir, name + "{}.txt")
|
||||||
|
files = [path.format(index) for index in range(10)]
|
||||||
|
[_create_tree(file) for file in files]
|
||||||
|
assert all([op.exists(file) for file in files]) is True
|
||||||
|
yield files
|
||||||
|
|
||||||
|
|
||||||
|
# NOTE: both legacy and modern test "pass" on windows, however sometimes with the same path
|
||||||
|
# they do not actually recycle files but delete them. Noticed this when testing with the
|
||||||
|
# recycle bin open, noticed later tests actually worked, modern version can actually detect
|
||||||
|
# when this happens but not stop it at this moment, and we need a way to verify it when testing.
|
||||||
|
def test_trash_long_file_modern(longfile):
|
||||||
|
_trash_file(longfile, s2t_modern)
|
||||||
|
|
||||||
|
|
||||||
|
def test_trash_long_multifile_modern(longfiles):
|
||||||
|
_trash_multifile(longfiles, s2t_modern)
|
||||||
|
|
||||||
|
|
||||||
|
# @pytest.skipif(
|
||||||
|
# op.splitdrive(os.getcwd())[0] != op.splitdrive(gettempdir())[0],
|
||||||
|
# "Cannot trash long path from other drive",
|
||||||
|
# )
|
||||||
|
# def test_trash_long_folder_modern(self):
|
||||||
|
# self._trash_folder(s2t_modern)
|
||||||
|
|
||||||
|
|
||||||
|
def test_trash_long_file_legacy(longfile):
|
||||||
|
_trash_file(longfile, s2t_legacy)
|
||||||
|
|
||||||
|
|
||||||
|
def test_trash_long_multifile_legacy(longfiles):
|
||||||
|
_trash_multifile(longfiles, s2t_legacy)
|
||||||
|
|
||||||
|
|
||||||
|
# @pytest.skipif(
|
||||||
|
# op.splitdrive(os.getcwd())[0] != op.splitdrive(gettempdir())[0],
|
||||||
|
# "Cannot trash long path from other drive",
|
||||||
|
# )
|
||||||
|
# def test_trash_long_folder_legacy(self):
|
||||||
|
# self._trash_folder(s2t_legacy)
|
||||||
43
tests/test_script_main.py
Normal file
43
tests/test_script_main.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# encoding: utf-8
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import pytest
|
||||||
|
from tempfile import NamedTemporaryFile
|
||||||
|
from os import path as op
|
||||||
|
|
||||||
|
from send2trash.__main__ import main as trash_main
|
||||||
|
|
||||||
|
# Only import HOMETRASH on supported platforms
|
||||||
|
if sys.platform != "win32":
|
||||||
|
from send2trash.plat_other import HOMETRASH
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def file():
|
||||||
|
file = NamedTemporaryFile(
|
||||||
|
dir=op.expanduser("~"), prefix="send2trash_test", delete=False
|
||||||
|
)
|
||||||
|
file.close()
|
||||||
|
# Verify file was actually created
|
||||||
|
assert op.exists(file.name) is True
|
||||||
|
yield file.name
|
||||||
|
# Cleanup trash files on supported platforms
|
||||||
|
if sys.platform != "win32":
|
||||||
|
name = op.basename(file.name)
|
||||||
|
# Remove trash files if they exist
|
||||||
|
if op.exists(op.join(HOMETRASH, "files", name)):
|
||||||
|
os.remove(op.join(HOMETRASH, "files", name))
|
||||||
|
os.remove(op.join(HOMETRASH, "info", name + ".trashinfo"))
|
||||||
|
if op.exists(file.name):
|
||||||
|
os.remove(file.name)
|
||||||
|
|
||||||
|
|
||||||
|
def test_trash(file):
|
||||||
|
trash_main(["-v", file])
|
||||||
|
assert op.exists(file) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_args(file):
|
||||||
|
pytest.raises(SystemExit, trash_main, [])
|
||||||
|
pytest.raises(SystemExit, trash_main, ["-v"])
|
||||||
|
assert op.exists(file) is True
|
||||||
16
tox.ini
16
tox.ini
@@ -1,11 +1,23 @@
|
|||||||
[tox]
|
[tox]
|
||||||
envlist = py27,py34,py35,py36
|
envlist = py{27,34,35,36,37,38,39,310}
|
||||||
skip_missing_interpreters = True
|
skip_missing_interpreters = True
|
||||||
|
|
||||||
[testenv]
|
[testenv]
|
||||||
|
deps =
|
||||||
|
flake8
|
||||||
|
pytest
|
||||||
|
pywin32; sys_platform == 'win32'
|
||||||
|
pyobjc-framework-Cocoa; sys_platform == 'darwin'
|
||||||
commands =
|
commands =
|
||||||
python setup.py test
|
flake8
|
||||||
|
pytest
|
||||||
|
|
||||||
[testenv:py27]
|
[testenv:py27]
|
||||||
deps =
|
deps =
|
||||||
configparser
|
configparser
|
||||||
|
{[testenv]deps}
|
||||||
|
|
||||||
|
[flake8]
|
||||||
|
exclude = .tox,env,build
|
||||||
|
max-line-length = 120
|
||||||
|
ignore = E731,E203,E501,W503
|
||||||
Reference in New Issue
Block a user