Skip to content

Commit

Permalink
Fix sending to trash not working on Python 3.6+ (pymedusa#6625)
Browse files Browse the repository at this point in the history
* send2trash v1.5.0

* Apply fix

* Update changelog
  • Loading branch information
sharkykh authored and Thilas committed Jun 5, 2019
1 parent 86e6103 commit c5175bf
Show file tree
Hide file tree
Showing 9 changed files with 155 additions and 52 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
- Fixed snatching of air by date shows specials ([#6457](https://github.com/pymedusa/Medusa/pull/6457))
- Fixed email notifier name parser warning for ABD episodes ([#6527](https://github.com/pymedusa/Medusa/pull/6527))
- Fixed download of multi episode releases without single results ([#6537](https://github.com/pymedusa/Medusa/pull/6537))
- Fixed "send to trash" option not doing anything (Python 3.6 and higher) ([#6625](https://github.com/pymedusa/Medusa/pull/6625))

## 0.3.1 (2019-03-20)

Expand Down
2 changes: 1 addition & 1 deletion lib/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
:: | `pytimeparse` | [1.1.5](https://pypi.org/project/pytimeparse/1.1.5/) | **`medusa`** | lib | **Modified**: [#1792](https://github.com/pymedusa/Medusa/pull/1792)
:: | `pytvmaze` | [2.0.7](https://pypi.org/project/pytvmaze/2.0.7/) | **`medusa`** | lib | **Modified**: [#1706](https://github.com/pymedusa/Medusa/pull/1706)
:: | `rtorrent-python` | [0.2.9](https://pypi.org/project/rtorrent-python/0.2.9/) | **`medusa`** | lib | Module: `rtorrent`<br>**Modified**: [commit log](https://github.com/pymedusa/Medusa/commits/master/lib/rtorrent)
:: | `send2trash` | [1.3.0](https://pypi.org/project/send2trash/1.3.0/) | **`medusa`** | lib | **Modified**
:: | `send2trash` | [1.5.0](https://pypi.org/project/Send2Trash/1.5.0/) | **`medusa`** | lib | **Modified**: Applied [hsoft/send2trash#33](https://github.com/hsoft/send2trash/pull/33)
:: | `shutil_custom` | - | **`medusa`** | lib | **Custom**
:: | `simpleanidb` | pymedusa/[5d26c8c](https://github.com/pymedusa/simpleanidb/tree/5d26c8c146891225c05651821ef34ced0c118221) | **`medusa`** | lib | -
:: | `tmdbsimple` | [2.2.0](https://pypi.org/project/tmdbsimple/2.2.0/) | **`medusa`** | lib | **Modified**: [#4026](https://github.com/pymedusa/Medusa/pull/4026) -- [Upstream PR](https://github.com/celiao/tmdbsimple/pull/52)
Expand Down
2 changes: 2 additions & 0 deletions lib/send2trash/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

import sys

from .exceptions import TrashPermissionError

if sys.platform == 'darwin':
from .plat_osx import send2trash
elif sys.platform == 'win32':
Expand Down
21 changes: 14 additions & 7 deletions lib/send2trash/compat.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
# Copyright 2013 Hardcoded Software (http://www.hardcoded.net)
# 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
# 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

import sys
if sys.version < '3':
text_type = unicode
binary_type = str
else:
import os

PY3 = sys.version_info[0] >= 3
if PY3:
text_type = str
binary_type = bytes
if os.supports_bytes_environ:
# environb will be unset under Windows, but then again we're not supposed to use it.
environb = os.environb
else:
text_type = unicode
binary_type = str
environb = os.environ
25 changes: 25 additions & 0 deletions lib/send2trash/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import errno
from .compat import PY3

if PY3:
_permission_error = PermissionError
else:
_permission_error = OSError

class TrashPermissionError(_permission_error):
"""A permission error specific to a trash directory.
Raising this error indicates that permissions prevent us efficiently
trashing a file, although we might still have permission to delete it.
This is *not* used when permissions prevent removing the file itself:
that will be raised as a regular PermissionError (OSError on Python 2).
Application code that catches this may try to simply delete the file,
or prompt the user to decide, or (on Freedesktop platforms), move it to
'home trash' as a fallback. This last option probably involves copying the
data between partitions, devices, or network drives, so we don't do it as
a fallback.
"""
def __init__(self, filename):
_permission_error.__init__(self, errno.EACCES, "Permission denied",
filename)
11 changes: 8 additions & 3 deletions lib/send2trash/plat_gio.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
# Copyright 2013 Hardcoded Software (http://www.hardcoded.net)
# 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
# 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 gi.repository import GObject, Gio
from .exceptions import TrashPermissionError

def send2trash(path):
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)
6 changes: 3 additions & 3 deletions lib/send2trash/plat_osx.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Copyright 2013 Hardcoded Software (http://www.hardcoded.net)
# 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
# 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
Expand Down
90 changes: 60 additions & 30 deletions lib/send2trash/plat_other.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Copyright 2013 Hardcoded Software (http://www.hardcoded.net)
# 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
# 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

# This is a reimplementation of plat_other.py with reference to the
Expand All @@ -16,6 +16,7 @@

from __future__ import unicode_literals

import errno
import sys
import os
import os.path as op
Expand All @@ -27,28 +28,47 @@
# Python 2
from urllib import quote

FILES_DIR = 'files'
INFO_DIR = 'info'
INFO_SUFFIX = '.trashinfo'
from .compat import text_type, environb
from .exceptions import TrashPermissionError

try:
fsencode = os.fsencode # Python 3
fsdecode = os.fsdecode
except AttributeError:
def fsencode(u): # Python 2
return u.encode(sys.getfilesystemencoding())
def fsdecode(b):
return b.decode(sys.getfilesystemencoding())
# The Python 3 versions are a bit smarter, handling surrogate escapes,
# but these should work in most cases.

FILES_DIR = b'files'
INFO_DIR = b'info'
INFO_SUFFIX = b'.trashinfo'

# Default of ~/.local/share [3]
XDG_DATA_HOME = op.expanduser(os.environ.get('XDG_DATA_HOME', '~/.local/share'))
HOMETRASH = op.join(XDG_DATA_HOME, 'Trash')
XDG_DATA_HOME = op.expanduser(environb.get(b'XDG_DATA_HOME', b'~/.local/share'))
HOMETRASH_B = op.join(XDG_DATA_HOME, b'Trash')
HOMETRASH = fsdecode(HOMETRASH_B)

uid = os.getuid()
TOPDIR_TRASH = '.Trash'
TOPDIR_FALLBACK = '.Trash-' + str(uid)
TOPDIR_TRASH = b'.Trash'
TOPDIR_FALLBACK = b'.Trash-' + text_type(uid).encode('ascii')

def is_parent(parent, path):
path = op.realpath(path) # In case it's a symlink
if isinstance(path, text_type):
path = fsencode(path)
parent = op.realpath(parent)
if isinstance(parent, text_type):
parent = fsencode(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
# ...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)
Expand All @@ -75,11 +95,11 @@ def trash_move(src, dst, topdir=None):
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)
destname = base_name + b' ' + text_type(counter).encode('ascii') + 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))
Expand All @@ -99,14 +119,14 @@ def find_ext_volume_global_trash(volume_root):
trash_dir = op.join(volume_root, TOPDIR_TRASH)
if not op.exists(trash_dir):
return None

mode = os.lstat(trash_dir).st_mode
# vol/.Trash must be a directory, cannot be a symlink, and must have the
# sticky bit set.
if not op.isdir(trash_dir) or op.islink(trash_dir) or not (mode & stat.S_ISVTX):
return None

trash_dir = op.join(trash_dir, str(uid))
trash_dir = op.join(trash_dir, text_type(uid).encode('ascii'))
try:
check_create(trash_dir)
except OSError:
Expand All @@ -116,9 +136,13 @@ def find_ext_volume_global_trash(volume_root):
def find_ext_volume_fallback_trash(volume_root):
# from [2] Trash directories (1) create a .Trash-$uid dir.
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)
# Try to make the directory, if we lack permission, raise TrashPermissionError
try:
check_create(trash_dir)
except OSError as e:
if e.errno == errno.EACCES:
raise TrashPermissionError(e.filename)
raise
return trash_dir

def find_ext_volume_trash(volume_root):
Expand All @@ -132,31 +156,37 @@ def get_dev(path):
return os.lstat(path).st_dev

def send2trash(path):
if not isinstance(path, str):
# path = str(path, sys.getfilesystemencoding()) # removed invalid arg passed to str function, shouldn't be used anyway
path = str(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):
if not op.exists(path_b):
raise OSError("File not found: %s" % path)
# ...should check whether the user has the necessary permissions to delete
# it, before starting the trashing operation itself. [2]
if not os.access(path, os.W_OK):
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)
path_dev = get_dev(path_b)

# 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('~'))
trash_dev = get_dev(op.expanduser(b'~'))

if path_dev == trash_dev:
topdir = XDG_DATA_HOME
dest_trash = HOMETRASH
dest_trash = HOMETRASH_B
else:
topdir = find_mount_point(path)
topdir = find_mount_point(path_b)
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)
trash_move(path_b, dest_trash, topdir)
49 changes: 41 additions & 8 deletions lib/send2trash/plat_win.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
# Copyright 2013 Hardcoded Software (http://www.hardcoded.net)
# 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
# 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 windll, Structure, byref, c_uint
from ctypes import (windll, Structure, byref, c_uint,
create_unicode_buffer, addressof,
GetLastError, FormatError)
from ctypes.wintypes import HWND, UINT, LPCWSTR, BOOL
import os.path as op

from .compat import text_type

kernel32 = windll.kernel32
GetShortPathNameW = kernel32.GetShortPathNameW

shell32 = windll.shell32
SHFileOperationW = shell32.SHFileOperationW


class SHFILEOPSTRUCTW(Structure):
_fields_ = [
("hwnd", HWND),
Expand All @@ -27,6 +33,7 @@ class SHFILEOPSTRUCTW(Structure):
("lpszProgressTitle", LPCWSTR),
]


FO_MOVE = 1
FO_COPY = 2
FO_DELETE = 3
Expand All @@ -38,22 +45,48 @@ class SHFILEOPSTRUCTW(Structure):
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(path):
if not isinstance(path, text_type):
path = text_type(path, 'mbcs')
if not op.isabs(path):
path = op.abspath(path)
path = get_short_path_name(path)
fileop = SHFILEOPSTRUCTW()
fileop.hwnd = 0
fileop.wFunc = FO_DELETE
fileop.pFrom = LPCWSTR(path + '\0')
# 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:
msg = "Couldn't perform operation. Error code: %d" % result
raise OSError(msg)

raise WindowsError(result, FormatError(result), path)

0 comments on commit c5175bf

Please sign in to comment.