Replace plat_other with one supporting the XDG Trash spec

Added tests for plat_other
This commit is contained in:
gbn 2011-03-10 04:55:46 -05:00
parent a8a771c9bd
commit 13b3943c82
2 changed files with 185 additions and 41 deletions

View File

@ -4,32 +4,84 @@
# 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 import logging
from datetime import datetime
from stat import *
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 = os.environ.get('XDG_DATA_HOME') or '~/.local/share'
if op.exists(candidate_path): HOMETRASH = op.expanduser(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.abspath(path)
parent = op.abspath(parent)
while path != '/':
path = op.abspath(op.join(path, '..'))
if path == parent:
return True
return False
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 == None or not is_parent(topdir, src):
src = op.abspath(src)
else:
src = op.relpath(src, topdir)
info = "[Trash Info]\n"
info += "Path=" + 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.
@ -38,35 +90,47 @@ def find_mount_point(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 & 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 == None:
trash_dir = find_ext_volume_fallback_trash(volume_root)
return trash_dir
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: try:
move_without_conflict(path, TRASH_PATH) trash_move(path, HOMETRASH, XDG_DATA_HOME)
except OSError: except OSError:
# We're probably on an external volume # Check if we're on an external volume
mount_point = find_mount_point(path) mount_point = find_mount_point(path)
dest_trash = find_ext_volume_trash(mount_point) dest_trash = find_ext_volume_trash(mount_point)
move_without_conflict(path, dest_trash) trash_move(path, dest_trash, mount_point)

80
test_plat_other.py Normal file
View File

@ -0,0 +1,80 @@
import unittest
import os
from os import path as op
from send2trash.plat_other import send2trash
from configparser import ConfigParser
#
# Warning: This test will shit up your Trash folder with test.txt files.
#
class TestHomeTrash(unittest.TestCase):
def setUp(self):
self.filePath = op.expanduser("~/test.txt")
def test_trash(self):
os.system('touch ' + self.filePath)
send2trash(self.filePath)
self.assertFalse(op.exists(self.filePath))
#
# Following cases use sudo, require ramfs
#
class TestRamFs(unittest.TestCase):
def setUp(self):
# Create a ramfs thingy.
self.trashFolder = '/tmp/trashtest'
os.system('sudo mkdir ' + self.trashFolder)
os.system('sudo mount -t ramfs none ' + self.trashFolder)
self.fileName = 'test.txt'
self.filePath = op.join(self.trashFolder, self.fileName)
def tearDown(self):
os.system('sudo umount ' + self.trashFolder)
os.system('sudo rmdir ' + self.trashFolder)
class TestTopdirTrash(TestRamFs):
def setUp(self):
TestRamFs.setUp(self)
# Create a .Trash dir w/ a sticky bit
os.system('sudo chmod a+w ' + self.trashFolder)
os.system('sudo mkdir ' + op.join(self.trashFolder, '.Trash'))
os.system('sudo chmod a+wt ' + op.join(self.trashFolder, '.Trash'))
def test_trash(self):
os.system('touch ' + self.filePath)
send2trash(self.filePath)
self.assertFalse(op.exists(self.filePath))
self.assertTrue(op.exists(op.join(self.trashFolder, '.Trash', str(os.getuid()), 'files', self.fileName)))
self.assertTrue(op.exists(op.join(self.trashFolder, '.Trash', 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.trashFolder, '.Trash', str(os.getuid()), 'info', self.fileName + '.trashinfo'))
self.assertEqual(self.fileName, cfg.get('Trash Info', 'Path', 1))
# Test .Trash-UID
class TestTopdirTrashFallback(TestRamFs):
def setUp(self):
TestRamFs.setUp(self)
# DONT Create a .Trash dir, but make sure the topdir is writable for uid dir
os.system('sudo chmod a+w ' + self.trashFolder)
def test_trash(self):
os.system('touch ' + self.filePath)
send2trash(self.filePath)
self.assertFalse(op.exists(self.filePath))
self.assertTrue(op.exists(op.join(self.trashFolder, '.Trash-' + str(os.getuid()), 'files', self.fileName)))
# Test failure
class TestTopdirFailure(TestRamFs):
def test_trash(self):
# a file to call our own
os.system('sudo chmod o+w ' + self.trashFolder)
os.system('touch ' + self.filePath)
os.system('sudo chmod o-w ' + self.trashFolder)
with self.assertRaises(OSError):
send2trash(self.filePath)
self.assertTrue(op.exists(self.filePath))
if __name__ == '__main__':
unittest.main()