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

27 Commits

Author SHA1 Message Date
23ce7b8c16 Bump version 2021-05-14 21:44:21 -05:00
9b0d5796c1 Change conditional for macos pyobjc usage
macOS 11.x will occasionally identify as 10.16, since there was no real
reason to prevent on all supported platforms allow.
2021-05-14 21:40:16 -05:00
c8bcaea1e8 Update version and changelog for release 2021-04-20 17:35:59 -05:00
530e9b4bc6 Add initial pyobjc version for macOS
This is to help with issue #51.  Will not help in the case of python 2 or
older python 3 version < 3.6.
2021-04-13 22:36:10 -05:00
10c7693d11 Minor fixes to tests 2021-03-17 21:51:51 -05:00
356509120b Add some checks to catch test failure
Really just checking that the setup is able to create test files so it
is known they were there then removed.
Windows tests really need verification of
recycle, which is not present.
2021-03-17 20:52:16 -05:00
f9fcdb8d8c Fix legacy windows platform for multibyte unicode
- Add handling to create correctly sized buffer even with multibyte
characters as len() in python does not line up with what
create_unicode_buffer() needs for length.
- Add test for single and multiple files
2021-03-10 21:41:30 -06:00
af0c1ba704 More test fixes
- Fix travis windows env
- Fix exception type in test_plat_win
- Finish converting tests in test_plat_other, fix exception type
2021-03-10 18:57:35 -06:00
37be84d46e Update .travis.yml 2021-03-02 22:48:48 -06:00
9f76fbf036 Updates for tests on windows
- Other platform tests WIP
- Windows long directory tests WIP
2021-03-02 19:33:44 -06:00
a324923ffa Add IFileOperationProgressSink
- Add sink to allow detection of times when the file would be deleted
- Currently can detect, but not stop operations, more work needed
2021-03-02 19:23:43 -06:00
dbdcce8b04 First batch of updates to unit tests
- Remove content from __init__.py
- Change test_script_main to use pytest
- Update test_script_main to run on windows as well as linux
2021-03-02 00:26:29 -06:00
054d56c564 Update Tox and Travis configurations 2021-03-02 00:24:59 -06:00
33ed07811b Cleanup flake8 issues 2021-03-01 23:44:03 -06:00
5d3835735e Merge pull request #55 from juliangilbey/fix-win-test
Only import Windows-specific modules when on Windows
2021-01-28 23:53:24 -06:00
Julian Gilbey
741c7ad51f Only import Windows-specific modules when on Windows 2021-01-29 05:42:11 +00:00
2eb3242cd9 Merge pull request #54 from asellappen/master
Adding power support for this package to support arch independent
2021-01-21 19:11:15 -06:00
60bcb2c834 Merge pull request #47 from pracedru/master
Update plat_other.py
2021-01-21 19:00:46 -06:00
c411f4eae4 Merge branch 'master' into master 2021-01-21 19:00:33 -06:00
f64c69f905 Merge branch 'master' into master 2021-01-21 18:57:39 -06:00
00dfe77e40 Add console_script entry point, close #50 2021-01-12 18:22:23 -06:00
16a7115ff1 Merge pull request #52 from BoboTiG/impr-expand-ci
Expand supported Python versions
2021-01-12 16:58:09 -06:00
ec73b44c43 Merge pull request #53 from BoboTiG/fix-resource-warning
Fix ResourceWarning: unclosed file in setup.py
2021-01-12 16:53:59 -06:00
Arumugam
f62b4f1ffd Adding power support for this package to support arch indepentant 2020-12-15 06:08:33 +00:00
Mickaël Schoentgen
38ae2b63d2 Expand supported Python versions 2020-12-01 09:16:20 +01:00
Mickaël Schoentgen
cd8d9fb95e Fix ResourceWarning: unclosed file in setup.py
Also prevent potential identical warning in `plat_other.py`.
2020-12-01 08:45:46 +01:00
Magnus Møller Jørgensen
20bbab0b4c Update plat_other.py
The trash info file needs to exist before the file is moved into the trash folder. 
This is to conform to the events based detection of trashed files in gnome and other file managers.
2020-07-23 03:57:15 +02:00
17 changed files with 639 additions and 436 deletions

View File

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

View File

@@ -1,6 +1,16 @@
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 Version 1.6.0b1 -- 2020/06/18
----------------------------- -----------------------------

View File

@@ -24,7 +24,7 @@ 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/arsenetar/send2trash and install it with:: or you can download the source from http://github.com/arsenetar/send2trash and install it with::

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

View File

@@ -2,7 +2,7 @@ import errno
from .compat import PY3 from .compat import PY3
if PY3: if PY3:
_permission_error = PermissionError _permission_error = PermissionError # noqa: F821
else: else:
_permission_error = OSError _permission_error = OSError

View File

@@ -4,53 +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(paths):
if not isinstance(paths, list):
paths = [paths]
paths = [
path.encode("utf-8") if not isinstance(path, binary_type) else path
for path in paths
]
for path in paths:
fp = FSRef()
opts = kFSPathMakeRefDoNotFollowLeafSymlink
op_result = FSPathMakeRefWithOptions(path, opts, byref(fp), None)
check_op_result(op_result)
opts = kFSFileOperationDefaultOptions
op_result = FSMoveObjectToTrashSync(byref(fp), None, opts)
check_op_result(op_result)

View File

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

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

View File

@@ -111,10 +111,9 @@ def trash_move(src, dst, topdir=None):
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):

View File

@@ -77,8 +77,6 @@ def send2trash(paths):
paths = [op.abspath(path) if not op.isabs(path) else path for path in paths] paths = [op.abspath(path) if not op.isabs(path) else path for path in paths]
# get short path to handle path length issues # get short path to handle path length issues
paths = [get_short_path_name(path) for path in paths] 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 = SHFILEOPSTRUCTW()
fileop.hwnd = 0 fileop.hwnd = 0
fileop.wFunc = FO_DELETE fileop.wFunc = FO_DELETE
@@ -93,7 +91,15 @@ def send2trash(paths):
# NOTE: based on how python allocates memory for these types they should # 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 # 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'. # setting the last two characters to null using buffer[index] = '\0'.
buffer = create_unicode_buffer(paths, len(paths) + 2) # 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.pFrom = LPCWSTR(addressof(buffer))
fileop.pTo = None fileop.pTo = None
fileop.fFlags = FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_NOERRORUI | FOF_SILENT fileop.fFlags = FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_NOERRORUI | FOF_SILENT

View File

@@ -11,6 +11,7 @@ from platform import version
import pythoncom import pythoncom
import pywintypes import pywintypes
from win32com.shell import shell, shellcon from win32com.shell import shell, shellcon
from .IFileOperationProgressSink import CreateSink
def send2trash(paths): def send2trash(paths):
@@ -50,10 +51,11 @@ def send2trash(paths):
# actually try to perform the operation, this section may throw a # actually try to perform the operation, this section may throw a
# pywintypes.com_error which does not seem to create as nice of an # pywintypes.com_error which does not seem to create as nice of an
# error as OSError so wrapping with try to convert # error as OSError so wrapping with try to convert
sink = CreateSink()
try: try:
for path in paths: for path in paths:
item = shell.SHCreateItemFromParsingName(path, None, shell.IID_IShellItem) item = shell.SHCreateItemFromParsingName(path, None, shell.IID_IShellItem)
fileop.DeleteItem(item) fileop.DeleteItem(item, sink)
result = fileop.PerformOperations() result = fileop.PerformOperations()
aborted = fileop.GetAnyOperationsAborted() aborted = fileop.GetAnyOperationsAborted()
# if non-zero result or aborted throw an exception # if non-zero result or aborted throw an exception

View File

@@ -14,16 +14,17 @@ CLASSIFIERS = [
"Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Topic :: Desktop Environment :: File Managers", "Topic :: Desktop Environment :: File Managers",
] ]
LONG_DESCRIPTION = ( with open("README.rst", "rt") as f1, open("CHANGES.rst", "rt") as f2:
open("README.rst", "rt").read() + "\n\n" + open("CHANGES.rst", "rt").read() LONG_DESCRIPTION = f1.read() + "\n\n" + f2.read()
)
setup( setup(
name="Send2Trash", name="Send2Trash",
version="1.6.0b1", version="1.7.0a1",
author="Andrew Senetar", author="Andrew Senetar",
author_email="arsenetar@voltaicideas.net", author_email="arsenetar@voltaicideas.net",
packages=["send2trash"], packages=["send2trash"],
@@ -36,4 +37,5 @@ setup(
classifiers=CLASSIFIERS, classifiers=CLASSIFIERS,
extras_require={"win32": ["pywin32"]}, extras_require={"win32": ["pywin32"]},
project_urls={"Bug Reports": "https://github.com/arsenetar/send2trash/issues"}, project_urls={"Bug Reports": "https://github.com/arsenetar/send2trash/issues"},
entry_points={"console_scripts": ["send2trash=send2trash.__main__:main"]},
) )

View File

@@ -1,18 +0,0 @@
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

@@ -1,25 +1,82 @@
# 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 send2trash import TrashPermissionError
try: try:
from configparser import ConfigParser from configparser import ConfigParser
except ImportError: except ImportError:
# py2 # py2
from ConfigParser import ConfigParser 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. if sys.platform != "win32":
import send2trash.plat_other
from send2trash.plat_other import send2trash as s2t
HOMETRASH = send2trash.plat_other.HOMETRASH 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):
@@ -27,100 +84,48 @@ def touch(path):
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"))
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():
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") @pytest.fixture
class TestUnicodeTrash(unittest.TestCase): def testUnicodefile():
def setUp(self): name = u"send2trash_tést1"
self.name = u"send2trash_tést1" file = op.join(op.expanduser(b"~"), name.encode("utf-8"))
self.file = op.join(op.expanduser(b"~"), self.name.encode("utf-8")) touch(file)
touch(self.file) assert op.exists(file) is True
yield file
def test_trash_bytes(self): # Cleanup trash files on supported platforms
s2t(self.file) if sys.platform != "win32":
assert not op.exists(self.file) # Remove trash files if they exist
if op.exists(op.join(HOMETRASH, "files", name)):
def test_trash_unicode(self): os.remove(op.join(HOMETRASH, "files", name))
s2t(self.file.decode(sys.getfilesystemencoding())) os.remove(op.join(HOMETRASH, "info", name + ".trashinfo"))
assert not op.exists(self.file) if op.exists(file):
os.remove(file)
def tearDown(self):
if op.exists(self.file):
os.remove(self.file)
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_bytes(testUnicodefile):
# s2t(testUnicodefile)
# 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)
# @pytest.mark.skipif(_filesys_enc() == "ascii", reason="Requires Unicode filesystem")
class TestExtVol(unittest.TestCase): def test_trash_unicode(testUnicodefile):
def setUp(self): s2t(testUnicodefile.decode(sys.getfilesystemencoding()))
self.trashTopdir = mkdtemp(prefix="s2t") assert not op.exists(testUnicodefile)
class ExtVol:
def __init__(self, path):
self.trashTopdir = path
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
@@ -133,120 +138,98 @@ class TestExtVol(unittest.TestCase):
def s_ismount(path): def s_ismount(path):
if op.realpath(path) in ( if op.realpath(path) in (
op.realpath(self.trashTopdir), op.realpath(self.trashTopdir),
op.realpath(trashTopdir_b), 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): @pytest.fixture
def setUp(self): def testExtVol():
TestExtVol.setUp(self) trashTopdir = mkdtemp(prefix="s2t")
# Create a .Trash dir w/ a sticky bit volume = ExtVol(trashTopdir)
self.trashDir = op.join(self.trashTopdir, ".Trash") fileName = "test.txt"
os.mkdir(self.trashDir, 0o777 | stat.S_ISVTX) filePath = op.join(volume.trashTopdir, fileName)
touch(filePath)
assert op.exists(filePath) is True
yield volume, fileName, filePath
volume.cleanup()
def test_trash(self):
s2t(self.filePath) def test_trash_topdir(testExtVol):
self.assertFalse(op.exists(self.filePath)) trashDir = op.join(testExtVol[0].trashTopdir, ".Trash")
self.assertTrue( os.mkdir(trashDir, 0o777 | stat.S_ISVTX)
op.exists(op.join(self.trashDir, str(os.getuid()), "files", self.fileName))
s2t(testExtVol[2])
assert op.exists(testExtVol[2]) is False
assert (
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",)
) )
self.assertTrue( is True
op.exists( )
op.join( # info relative path (if another test is added, with the same fileName/Path,
self.trashDir, # then it gets renamed etc.)
str(os.getuid()), cfg = ConfigParser()
"info", cfg.read(op.join(trashDir, str(os.getuid()), "info", testExtVol[1] + ".trashinfo"))
self.fileName + ".trashinfo", assert (testExtVol[1] == cfg.get("Trash Info", "Path", raw=True)) is True
)
)
) def test_trash_topdir_fallback(testExtVol):
# info relative path (if another test is added, with the same fileName/Path, s2t(testExtVol[2])
# then it gets renamed etc.) assert op.exists(testExtVol[2]) is False
cfg = ConfigParser() assert (
cfg.read( op.exists(
op.join( op.join(
self.trashDir, str(os.getuid()), "info", self.fileName + ".trashinfo" testExtVol[0].trashTopdir,
".Trash-" + str(os.getuid()),
"files",
testExtVol[1],
) )
) )
self.assertEqual(self.fileName, cfg.get("Trash Info", "Path", raw=True)) is True
)
# Test .Trash-UID def test_trash_topdir_failure(testExtVol):
class TestTopdirTrashFallback(TestExtVol): os.chmod(testExtVol[0].trashTopdir, 0o500) # not writable to induce the exception
def test_trash(self): pytest.raises(TrashPermissionError, s2t, [testExtVol[2]])
touch(self.filePath) os.chmod(testExtVol[0].trashTopdir, 0o700) # writable to allow deletion
s2t(self.filePath)
self.assertFalse(op.exists(self.filePath))
self.assertTrue( def test_trash_symlink(testExtVol):
op.exists( # Use mktemp (race conditioney but no symlink equivalent)
op.join( # Since is_parent uses realpath(), and our getdev uses is_parent,
self.trashTopdir, # this should work
".Trash-" + str(os.getuid()), slDir = mktemp(prefix="s2t", dir=op.expanduser("~"))
"files", os.mkdir(op.join(testExtVol[0].trashTopdir, "subdir"), 0o700)
self.fileName, 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
)
# Test failure os.remove(slDir)
class TestTopdirFailure(TestExtVol):
def setUp(self):
TestExtVol.setUp(self)
os.chmod(self.trashTopdir, 0o500) # not writable to induce the exception
def test_trash(self):
with self.assertRaises(OSError):
s2t(self.filePath)
self.assertTrue(op.exists(self.filePath))
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
class TestSymlink(TestExtVol):
def setUp(self):
TestExtVol.setUp(self)
# Use mktemp (race conditioney but no symlink equivalent)
# Since is_parent uses realpath(), and our getdev uses is_parent,
# this should work
self.slDir = mktemp(prefix="s2t", dir=op.expanduser("~"))
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):
s2t(op.join(self.slDir, self.fileName))
self.assertFalse(op.exists(self.filePath))
self.assertTrue(
op.exists(
op.join(
self.trashTopdir,
".Trash-" + str(os.getuid()),
"files",
self.fileName,
)
)
)
def tearDown(self):
os.remove(self.slDir)
TestExtVol.tearDown(self)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,154 +1,203 @@
# coding: utf-8 # encoding: utf-8
import os import os
import shutil import shutil
import sys import sys
import unittest import pytest
from os import path as op from os import path as op
from tempfile import gettempdir
from send2trash import send2trash as s2t from send2trash import send2trash as s2t
# import the two versions as well as the "automatic" version # import the two versions as well as the "automatic" version
from send2trash.plat_win_modern import send2trash as s2t_modern if sys.platform == "win32":
from send2trash.plat_win_legacy import send2trash as s2t_legacy 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)
@unittest.skipIf(sys.platform != "win32", "Windows only") def _create_tree(path):
class TestNormal(unittest.TestCase): dirname = op.dirname(path)
def setUp(self): if not op.isdir(dirname):
self.dirname = "\\\\?\\" + op.join(gettempdir(), "python.send2trash") os.makedirs(dirname)
self.file = op.join(self.dirname, "testfile.txt") with open(path, "w") as writer:
self._create_tree(self.file) writer.write("send2trash test")
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") @pytest.fixture
class TestLongPath(unittest.TestCase): def testdir(tmp_path):
def setUp(self): dirname = "\\\\?\\" + str(tmp_path)
self.functions = {s2t: "auto", s2t_legacy: "legacy", s2t_modern: "modern"} assert op.exists(dirname) is True
filename = "A" * 100 yield dirname
self.dirname = "\\\\?\\" + op.join(gettempdir(), filename) shutil.rmtree(dirname, ignore_errors=True)
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): @pytest.fixture
dirname = op.dirname(path) def testfile(testdir):
if not op.isdir(dirname): file = op.join(testdir, "testfile.txt")
os.makedirs(dirname) _create_tree(file)
with open(path, "w") as writer: assert op.exists(file) is True
writer.write("Looong filename!") yield file
# Note dir will cleanup the file
def _trash_file(self, fcn):
fcn(self.file)
self.assertFalse(op.exists(self.file))
def _trash_multifile(self, fcn): @pytest.fixture
fcn(self.files) def testfiles(testdir):
self.assertFalse(any([op.exists(file) for file in self.files])) 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(self, fcn):
fcn(self.dirname)
self.assertFalse(op.exists(self.dirname))
def test_trash_file(self): def _trash_folder(dir, fcn):
self._trash_file(s2t) fcn(dir)
assert op.exists(dir) is False
def test_trash_multifile(self):
self._trash_multifile(s2t)
@unittest.skipIf( def _trash_file(file, fcn):
op.splitdrive(os.getcwd())[0] != op.splitdrive(gettempdir())[0], fcn(file)
"Cannot trash long path from other drive", assert op.exists(file) is False
)
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): def _trash_multifile(files, fcn):
self._trash_multifile(s2t_modern) fcn(files)
assert any([op.exists(file) for file in files]) is False
@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): def _file_not_found(dir, fcn):
self._trash_file(s2t_legacy) file = op.join(dir, "otherfile.txt")
pytest.raises(OSError, fcn, file)
def test_trash_multifile_legacy(self):
self._trash_multifile(s2t_legacy)
@unittest.skipIf( def _multi_byte_unicode(dir, fcn):
op.splitdrive(os.getcwd())[0] != op.splitdrive(gettempdir())[0], single_file = op.join(dir, "😇.txt")
"Cannot trash long path from other drive", _create_tree(single_file)
) assert op.exists(single_file) is True
def test_trash_folder_legacy(self): fcn(single_file)
self._trash_folder(s2t_legacy) 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)

View File

@@ -1,32 +1,43 @@
# encoding: utf-8 # encoding: utf-8
import os import os
import unittest import sys
import pytest
from tempfile import NamedTemporaryFile from tempfile import NamedTemporaryFile
from os import path as op from os import path as op
from send2trash.__main__ import main as trash_main from send2trash.__main__ import main as trash_main
from tests.test_plat_other import HOMETRASH
# Only import HOMETRASH on supported platforms
if sys.platform != "win32":
from send2trash.plat_other import HOMETRASH
class TestMainTrash(unittest.TestCase): @pytest.fixture
def setUp(self): def file():
self.file = NamedTemporaryFile(dir=op.expanduser('~'), prefix='send2trash_test', delete=False) file = NamedTemporaryFile(
dir=op.expanduser("~"), prefix="send2trash_test", delete=False
def test_trash(self): )
trash_main(['-v', self.file.name]) file.close()
self.assertFalse(op.exists(self.file.name)) # Verify file was actually created
assert op.exists(file.name) is True
def test_no_args(self): yield file.name
self.assertRaises(SystemExit, trash_main, []) # Cleanup trash files on supported platforms
self.assertRaises(SystemExit, trash_main, ['-v']) if sys.platform != "win32":
self.assertTrue(op.exists(self.file.name)) name = op.basename(file.name)
trash_main([self.file.name]) # Trash the file so tearDown runs properly # Remove trash files if they exist
if op.exists(op.join(HOMETRASH, "files", name)):
def tearDown(self): os.remove(op.join(HOMETRASH, "files", name))
name = op.basename(self.file.name) os.remove(op.join(HOMETRASH, "info", name + ".trashinfo"))
os.remove(op.join(HOMETRASH, 'files', name)) if op.exists(file.name):
os.remove(op.join(HOMETRASH, 'info', name + '.trashinfo')) os.remove(file.name)
if __name__ == '__main__': def test_trash(file):
unittest.main() 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

18
tox.ini
View File

@@ -1,21 +1,21 @@
[tox] [tox]
envlist = py27,py34,py35,py36,py3-win envlist = py{27,34,35,36,37,38,39,310}
skip_missing_interpreters = True skip_missing_interpreters = True
[testenv] [testenv]
platform = linux deps =
flake8
pytest
pywin32; sys_platform == 'win32'
pyobjc-framework-Cocoa; sys_platform == 'darwin'
commands = commands =
python setup.py test --test-suite tests.TestSuite flake8
pytest
[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
{[testenv]deps}
[flake8] [flake8]
exclude = .tox,env,build exclude = .tox,env,build