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

18 Commits
1.1.0 ... 1.2.0

Author SHA1 Message Date
Virgil Dupras
0f95d7506e v1.2.0 2011-03-16 10:27:41 +01:00
gbn
b415ac86e3 Modification to symlink test case (that will actually fail when it should -- find_mount_point using abspath instead of realpath.) 2011-03-13 15:35:14 -04:00
gbn
eeaf4e8ffa Add a test case for a path containing a symlink. 2011-03-13 15:17:13 -04:00
gbn
798893215c Make the (still ugly) test no longer rely on ramfs/being root 2011-03-13 14:43:24 -04:00
gbn
aee2b7a8af Check access and devices before attempting trash. 2011-03-13 14:40:52 -04:00
gbn
d090156c45 Use realpath to find mountpoint 2011-03-13 14:38:03 -04:00
Virgil Dupras
358b705cbc Made a few minor style fixes, and added a proper error in cases where the target path of send2trash() doesn't exist. 2011-03-12 11:48:19 +01:00
gbn
eedbe258cb URL Escape the Path in trashinfo 2011-03-10 14:56:19 -05:00
gbn
18e3187c2f Replace == None with is None 2011-03-10 12:22:21 -05:00
gbn
8001be8f37 Remove import * 2011-03-10 12:21:05 -05:00
gbn
13b3943c82 Replace plat_other with one supporting the XDG Trash spec
Added tests for plat_other
2011-03-10 04:55:46 -05:00
Virgil Dupras
a8a771c9bd Merged default branch with py3k. Py3k version of send2trash is now the default one (python 2 version is in the py2k branch). 2011-02-14 11:00:09 +01:00
Virgil Dupras
9189e685b1 Adjusted packaging metadata for 1.1.0.
--HG--
branch : py3k
2010-10-18 12:22:13 +02:00
Virgil Dupras
04ee6eaf9f Added tag 1.1.0 for changeset de5f43fcce5e
--HG--
branch : py3k
2010-10-18 12:13:42 +02:00
Virgil Dupras
51d8a51cb7 Added a setuptools-crappiness notice in the README. 2010-07-13 12:19:19 +02:00
Virgil Dupras
86450a3dee v1.0.2 2010-07-10 07:07:42 +02:00
Virgil Dupras
31907c9c4a Fixed a bug in plat_other where conflict handling wouldn't be done correctly in external volume. Thanks to John Benediktsson for the tip. 2010-07-09 21:49:46 -07:00
Virgil Dupras
2572a7c00c Fixed an infinite loop in plat_other when using a relative path in a mounted directory. 2010-07-09 21:46:19 -07:00
7 changed files with 269 additions and 51 deletions

View File

@@ -1,2 +1,3 @@
48c2103380f5e7deca49364f44fb31ded9942bb7 1.0.0 48c2103380f5e7deca49364f44fb31ded9942bb7 1.0.0
a7e04d8e47e161daaa1f031a7c1e98c52fa269ac 1.0.1 a7e04d8e47e161daaa1f031a7c1e98c52fa269ac 1.0.1
de5f43fcce5e776eb28306951939afb9946cbd3d 1.1.0

View File

@@ -1,6 +1,11 @@
Changes Changes
======= =======
Version 1.2.0 -- 2011/03/16
---------------------------
* Improved ``plat_other`` to follow freedesktop.org trash specification.
Version 1.1.0 -- 2010/10/18 Version 1.1.0 -- 2010/10/18
--------------------------- ---------------------------

1
MANIFEST.in Normal file
View File

@@ -0,0 +1 @@
include CHANGES

2
README
View File

@@ -5,7 +5,7 @@ Send2Trash -- Send files to trash on all platforms
This is a Python 3 package. The Python 2 package is at http://pypi.python.org/pypi/Send2Trash . This is a Python 3 package. The Python 2 package is at http://pypi.python.org/pypi/Send2Trash .
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 uses native (and ugly) ``SHFileOperation`` win32 calls. On other platforms, it moves the file to the first folder it finds that looks like a trash (so far, it's known to work on Ubuntu). *all platforms*. On OS X, it uses native ``FSMoveObjectToTrashSync`` Cocoa calls, on Windows, it uses native (and ugly) ``SHFileOperation`` win32 calls. On other platforms, it follows 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.

View File

@@ -4,69 +4,151 @@
# 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
# This is a reimplementation of plat_other.py with reference to the
# freedesktop.org trash specification:
# [1] http://www.freedesktop.org/wiki/Specifications/trash-spec
# [2] http://www.ramendik.ru/docs/trashspec.html
# See also:
# [3] http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html
#
# For external volumes this implementation will raise an exception if it can't
# find or create the user's trash directory.
import sys import sys
import os import os
import os.path as op import os.path as op
import logging from datetime import datetime
import stat
from urllib.parse import quote
CANDIDATES = [ FILES_DIR = 'files'
'~/.local/share/Trash/files', INFO_DIR = 'info'
'~/.Trash', INFO_SUFFIX = '.trashinfo'
]
for candidate in CANDIDATES: # Default of ~/.local/share [3]
candidate_path = op.expanduser(candidate) XDG_DATA_HOME = op.expanduser(os.environ.get('XDG_DATA_HOME', '~/.local/share'))
if op.exists(candidate_path): HOMETRASH = op.join(XDG_DATA_HOME, 'Trash')
TRASH_PATH = candidate_path
break
else:
logging.warning("Can't find path for Trash")
TRASH_PATH = op.expanduser('~/.Trash')
EXTERNAL_CANDIDATES = [ uid = os.getuid()
'.Trash-1000/files', TOPDIR_TRASH = '.Trash'
'.Trash/files', TOPDIR_FALLBACK = '.Trash-' + str(uid)
'.Trash-1000',
'.Trash', def is_parent(parent, path):
] path = op.realpath(path) # In case it's a symlink
parent = op.realpath(parent)
return path.startswith(parent)
def format_date(date):
return date.strftime("%Y-%m-%dT%H:%M:%S")
def info_for(src, topdir):
# ...it MUST not include a ".."" directory, and for files not "under" that
# directory, absolute pathnames must be used. [2]
if topdir is None or not is_parent(topdir, src):
src = op.abspath(src)
else:
src = op.relpath(src, topdir)
info = "[Trash Info]\n"
info += "Path=" + quote(src) + "\n"
info += "DeletionDate=" + format_date(datetime.now()) + "\n"
return info
def check_create(dir):
# use 0700 for paths [3]
if not op.exists(dir):
os.makedirs(dir, 0o700)
def trash_move(src, dst, topdir=None):
filename = op.basename(src)
filespath = op.join(dst, FILES_DIR)
infopath = op.join(dst, INFO_DIR)
base_name, ext = op.splitext(filename)
counter = 0
destname = filename
while op.exists(op.join(filespath, destname)) or op.exists(op.join(infopath, destname + INFO_SUFFIX)):
counter += 1
destname = '%s %s%s' % (base_name, counter, ext)
check_create(filespath)
check_create(infopath)
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.
path = op.abspath(path) # Required to avoid infinite loop # Use realpath in case it's a symlink
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_trash(volume_root): def find_ext_volume_global_trash(volume_root):
for candidate in EXTERNAL_CANDIDATES: # from [2] Trash directories (1) check for a .Trash dir with the right
candidate_path = op.join(volume_root, candidate) # permissions set.
if op.exists(candidate_path): trash_dir = op.join(volume_root, TOPDIR_TRASH)
return candidate_path if not op.exists(trash_dir):
else: return None
# Something's wrong here. Screw that, just create a .Trash folder
trash_path = op.join(volume_root, '.Trash') mode = os.lstat(trash_dir).st_mode
os.mkdir(trash_path) # vol/.Trash must be a directory, cannot be a symlink, and must have the
return trash_path # sticky bit set.
if not op.isdir(trash_dir) or op.islink(trash_dir) or not (mode & stat.S_ISVTX):
return None
def move_without_conflict(src, dst): trash_dir = op.join(trash_dir, str(uid))
filename = op.basename(src) try:
destpath = op.join(dst, filename) check_create(trash_dir)
counter = 0 except OSError:
while op.exists(destpath): return None
counter += 1 return trash_dir
base_name, ext = op.splitext(filename)
new_filename = '{0} {1}{2}'.format(base_name, counter, ext) def find_ext_volume_fallback_trash(volume_root):
destpath = op.join(dst, new_filename) # from [2] Trash directories (1) create a .Trash-$uid dir.
os.rename(src, destpath) trash_dir = op.join(volume_root, TOPDIR_FALLBACK)
# Try to make the directory, if we can't the OSError exception will escape
# be thrown out of send2trash.
check_create(trash_dir)
return trash_dir
def find_ext_volume_trash(volume_root):
trash_dir = find_ext_volume_global_trash(volume_root)
if trash_dir is None:
trash_dir = find_ext_volume_fallback_trash(volume_root)
return trash_dir
# Pull this out so it's easy to stub (to avoid stubbing lstat itself)
def get_dev(path):
return os.lstat(path).st_dev
def send2trash(path): def send2trash(path):
if not isinstance(path, str): if not isinstance(path, str):
path = str(path, sys.getfilesystemencoding()) path = str(path, sys.getfilesystemencoding())
try: if not op.exists(path):
move_without_conflict(path, TRASH_PATH) raise OSError("File not found: %s" % path)
except OSError: # ...should check whether the user has the necessary permissions to delete
# We're probably on an external volume # it, before starting the trashing operation itself. [2]
mount_point = find_mount_point(path) if not os.access(path, os.W_OK):
dest_trash = find_ext_volume_trash(mount_point) raise OSError("Permission denied: %s" % path)
move_without_conflict(path, dest_trash) # if the file to be trashed is on the same device as HOMETRASH we
# want to move it there.
path_dev = get_dev(path)
# If XDG_DATA_HOME or HOMETRASH do not yet exist we need to stat the
# home directory, and these paths will be created further on if needed.
trash_dev = get_dev(op.expanduser('~'))
if path_dev == trash_dev:
topdir = XDG_DATA_HOME
dest_trash = HOMETRASH
else:
topdir = find_mount_point(path)
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, dest_trash, topdir)

View File

@@ -11,8 +11,6 @@ CLASSIFIERS = [
'Operating System :: Microsoft :: Windows', 'Operating System :: Microsoft :: Windows',
'Operating System :: POSIX', 'Operating System :: POSIX',
'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3',
'Programming Language :: Objective C',
'Programming Language :: C',
'Topic :: Desktop Environment :: File Managers', 'Topic :: Desktop Environment :: File Managers',
] ]
@@ -20,7 +18,7 @@ LONG_DESCRIPTION = open('README', 'rt').read() + '\n\n' + open('CHANGES', 'rt').
setup( setup(
name='Send2Trash3k', name='Send2Trash3k',
version='1.1.0', version='1.2.0',
author='Hardcoded Software', author='Hardcoded Software',
author_email='hsoft@hardcoded.net', author_email='hsoft@hardcoded.net',
packages=['send2trash'], packages=['send2trash'],

131
test_plat_other.py Normal file
View File

@@ -0,0 +1,131 @@
import unittest
import os
from os import path as op
import send2trash.plat_other
from send2trash.plat_other import send2trash as s2t
from configparser import ConfigParser
from tempfile import mkdtemp, NamedTemporaryFile, mktemp
import shutil
import stat
# Could still use cleaning up. But no longer relies on ramfs.
def touch(path):
with open(path, 'a'):
os.utime(path, None)
class TestHomeTrash(unittest.TestCase):
def setUp(self):
self.file = NamedTemporaryFile(dir=op.expanduser("~"),
prefix='send2trash_test', delete=False)
def test_trash(self):
s2t(self.file.name)
self.assertFalse(op.exists(self.file.name))
def tearDown(self):
hometrash = send2trash.plat_other.HOMETRASH
name = op.basename(self.file.name)
os.remove(op.join(hometrash, 'files', name))
os.remove(op.join(hometrash, 'info', name+'.trashinfo'))
#
# Tests for files on some other volume than the user's home directory.
#
# What we need to stub:
# * 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 TestExtVol(unittest.TestCase):
def setUp(self):
self.trashTopdir = mkdtemp(prefix='s2t')
self.fileName = 'test.txt'
self.filePath = op.join(self.trashTopdir, self.fileName)
touch(self.filePath)
self.old_ismount = old_ismount = op.ismount
self.old_getdev = send2trash.plat_other.get_dev
def s_getdev(path):
from send2trash.plat_other import is_parent
st = os.lstat(path)
if is_parent(self.trashTopdir, path):
return 'dev'
return st
def s_ismount(path):
if op.realpath(path) == op.realpath(self.trashTopdir):
return True
return old_ismount(path)
send2trash.plat_other.os.path.ismount = s_ismount
send2trash.plat_other.get_dev = s_getdev
def tearDown(self):
send2trash.plat_other.get_dev = self.old_getdev
send2trash.plat_other.os.path.ismount = self.old_ismount
shutil.rmtree(self.trashTopdir)
class TestTopdirTrash(TestExtVol):
def setUp(self):
TestExtVol.setUp(self)
# Create a .Trash dir w/ a sticky bit
self.trashDir = op.join(self.trashTopdir, '.Trash')
os.mkdir(self.trashDir, 0o777|stat.S_ISVTX)
def test_trash(self):
s2t(self.filePath)
self.assertFalse(op.exists(self.filePath))
self.assertTrue(op.exists(op.join(self.trashDir, str(os.getuid()), 'files', self.fileName)))
self.assertTrue(op.exists(op.join(self.trashDir, str(os.getuid()), 'info', self.fileName + '.trashinfo')))
# info relative path (if another test is added, with the same fileName/Path,
# then it gets renamed etc.)
cfg = ConfigParser()
cfg.read(op.join(self.trashDir, str(os.getuid()), 'info', self.fileName + '.trashinfo'))
self.assertEqual(self.fileName, cfg.get('Trash Info', 'Path', 1))
# 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
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()