Skip to content

Commit a739b0c

Browse files
sharkykhp0psicles
authored andcommitted
Fix sending to trash not working on Python 3.6+ (pymedusa#6625)
* send2trash v1.5.0 * Apply fix * Update changelog
1 parent e769480 commit a739b0c

File tree

9 files changed

+155
-52
lines changed

9 files changed

+155
-52
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
- Fixed snatching of air by date shows specials ([#6457](https://github.com/pymedusa/Medusa/pull/6457))
1414
- Fixed email notifier name parser warning for ABD episodes ([#6527](https://github.com/pymedusa/Medusa/pull/6527))
1515
- Fixed download of multi episode releases without single results ([#6537](https://github.com/pymedusa/Medusa/pull/6537))
16+
- Fixed "send to trash" option not doing anything (Python 3.6 and higher) ([#6625](https://github.com/pymedusa/Medusa/pull/6625))
1617

1718
## 0.3.1 (2019-03-20)
1819

lib/readme.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
:: | `pytimeparse` | [1.1.5](https://pypi.org/project/pytimeparse/1.1.5/) | **`medusa`** | lib | **Modified**: [#1792](https://github.com/pymedusa/Medusa/pull/1792)
99
:: | `pytvmaze` | [2.0.7](https://pypi.org/project/pytvmaze/2.0.7/) | **`medusa`** | lib | **Modified**: [#1706](https://github.com/pymedusa/Medusa/pull/1706)
1010
:: | `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)
11-
:: | `send2trash` | [1.3.0](https://pypi.org/project/send2trash/1.3.0/) | **`medusa`** | lib | **Modified**
11+
:: | `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)
1212
:: | `shutil_custom` | - | **`medusa`** | lib | **Custom**
1313
:: | `simpleanidb` | pymedusa/[5d26c8c](https://github.com/pymedusa/simpleanidb/tree/5d26c8c146891225c05651821ef34ced0c118221) | **`medusa`** | lib | -
1414
:: | `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)

lib/send2trash/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
import sys
88

9+
from .exceptions import TrashPermissionError
10+
911
if sys.platform == 'darwin':
1012
from .plat_osx import send2trash
1113
elif sys.platform == 'win32':

lib/send2trash/compat.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
1-
# Copyright 2013 Hardcoded Software (http://www.hardcoded.net)
1+
# Copyright 2017 Virgil Dupras
22

3-
# This software is licensed under the "BSD" License as described in the "LICENSE" file,
4-
# which should be included with this package. The terms are also available at
3+
# This software is licensed under the "BSD" License as described in the "LICENSE" file,
4+
# which should be included with this package. The terms are also available at
55
# http://www.hardcoded.net/licenses/bsd_license
66

77
import sys
8-
if sys.version < '3':
9-
text_type = unicode
10-
binary_type = str
11-
else:
8+
import os
9+
10+
PY3 = sys.version_info[0] >= 3
11+
if PY3:
1212
text_type = str
1313
binary_type = bytes
14+
if os.supports_bytes_environ:
15+
# environb will be unset under Windows, but then again we're not supposed to use it.
16+
environb = os.environb
17+
else:
18+
text_type = unicode
19+
binary_type = str
20+
environb = os.environ

lib/send2trash/exceptions.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import errno
2+
from .compat import PY3
3+
4+
if PY3:
5+
_permission_error = PermissionError
6+
else:
7+
_permission_error = OSError
8+
9+
class TrashPermissionError(_permission_error):
10+
"""A permission error specific to a trash directory.
11+
12+
Raising this error indicates that permissions prevent us efficiently
13+
trashing a file, although we might still have permission to delete it.
14+
This is *not* used when permissions prevent removing the file itself:
15+
that will be raised as a regular PermissionError (OSError on Python 2).
16+
17+
Application code that catches this may try to simply delete the file,
18+
or prompt the user to decide, or (on Freedesktop platforms), move it to
19+
'home trash' as a fallback. This last option probably involves copying the
20+
data between partitions, devices, or network drives, so we don't do it as
21+
a fallback.
22+
"""
23+
def __init__(self, filename):
24+
_permission_error.__init__(self, errno.EACCES, "Permission denied",
25+
filename)

lib/send2trash/plat_gio.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
1-
# Copyright 2013 Hardcoded Software (http://www.hardcoded.net)
1+
# Copyright 2017 Virgil Dupras
22

3-
# This software is licensed under the "BSD" License as described in the "LICENSE" file,
4-
# which should be included with this package. The terms are also available at
3+
# This software is licensed under the "BSD" License as described in the "LICENSE" file,
4+
# which should be included with this package. The terms are also available at
55
# http://www.hardcoded.net/licenses/bsd_license
66

77
from gi.repository import GObject, Gio
8+
from .exceptions import TrashPermissionError
89

910
def send2trash(path):
1011
try:
1112
f = Gio.File.new_for_path(path)
1213
f.trash(cancellable=None)
1314
except GObject.GError as e:
15+
if e.code == Gio.IOErrorEnum.NOT_SUPPORTED:
16+
# We get here if we can't create a trash directory on the same
17+
# device. I don't know if other errors can result in NOT_SUPPORTED.
18+
raise TrashPermissionError('')
1419
raise OSError(e.message)

lib/send2trash/plat_osx.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
# Copyright 2013 Hardcoded Software (http://www.hardcoded.net)
1+
# Copyright 2017 Virgil Dupras
22

3-
# This software is licensed under the "BSD" License as described in the "LICENSE" file,
4-
# which should be included with this package. The terms are also available at
3+
# This software is licensed under the "BSD" License as described in the "LICENSE" file,
4+
# which should be included with this package. The terms are also available at
55
# http://www.hardcoded.net/licenses/bsd_license
66

77
from __future__ import unicode_literals

lib/send2trash/plat_other.py

Lines changed: 60 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
# Copyright 2013 Hardcoded Software (http://www.hardcoded.net)
1+
# Copyright 2017 Virgil Dupras
22

3-
# This software is licensed under the "BSD" License as described in the "LICENSE" file,
4-
# which should be included with this package. The terms are also available at
3+
# This software is licensed under the "BSD" License as described in the "LICENSE" file,
4+
# which should be included with this package. The terms are also available at
55
# http://www.hardcoded.net/licenses/bsd_license
66

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

1717
from __future__ import unicode_literals
1818

19+
import errno
1920
import sys
2021
import os
2122
import os.path as op
@@ -27,28 +28,47 @@
2728
# Python 2
2829
from urllib import quote
2930

30-
FILES_DIR = 'files'
31-
INFO_DIR = 'info'
32-
INFO_SUFFIX = '.trashinfo'
31+
from .compat import text_type, environb
32+
from .exceptions import TrashPermissionError
33+
34+
try:
35+
fsencode = os.fsencode # Python 3
36+
fsdecode = os.fsdecode
37+
except AttributeError:
38+
def fsencode(u): # Python 2
39+
return u.encode(sys.getfilesystemencoding())
40+
def fsdecode(b):
41+
return b.decode(sys.getfilesystemencoding())
42+
# The Python 3 versions are a bit smarter, handling surrogate escapes,
43+
# but these should work in most cases.
44+
45+
FILES_DIR = b'files'
46+
INFO_DIR = b'info'
47+
INFO_SUFFIX = b'.trashinfo'
3348

3449
# Default of ~/.local/share [3]
35-
XDG_DATA_HOME = op.expanduser(os.environ.get('XDG_DATA_HOME', '~/.local/share'))
36-
HOMETRASH = op.join(XDG_DATA_HOME, 'Trash')
50+
XDG_DATA_HOME = op.expanduser(environb.get(b'XDG_DATA_HOME', b'~/.local/share'))
51+
HOMETRASH_B = op.join(XDG_DATA_HOME, b'Trash')
52+
HOMETRASH = fsdecode(HOMETRASH_B)
3753

3854
uid = os.getuid()
39-
TOPDIR_TRASH = '.Trash'
40-
TOPDIR_FALLBACK = '.Trash-' + str(uid)
55+
TOPDIR_TRASH = b'.Trash'
56+
TOPDIR_FALLBACK = b'.Trash-' + text_type(uid).encode('ascii')
4157

4258
def is_parent(parent, path):
4359
path = op.realpath(path) # In case it's a symlink
60+
if isinstance(path, text_type):
61+
path = fsencode(path)
4462
parent = op.realpath(parent)
63+
if isinstance(parent, text_type):
64+
parent = fsencode(parent)
4565
return path.startswith(parent)
4666

4767
def format_date(date):
4868
return date.strftime("%Y-%m-%dT%H:%M:%S")
4969

5070
def info_for(src, topdir):
51-
# ...it MUST not include a ".."" directory, and for files not "under" that
71+
# ...it MUST not include a ".." directory, and for files not "under" that
5272
# directory, absolute pathnames must be used. [2]
5373
if topdir is None or not is_parent(topdir, src):
5474
src = op.abspath(src)
@@ -75,11 +95,11 @@ def trash_move(src, dst, topdir=None):
7595
destname = filename
7696
while op.exists(op.join(filespath, destname)) or op.exists(op.join(infopath, destname + INFO_SUFFIX)):
7797
counter += 1
78-
destname = '%s %s%s' % (base_name, counter, ext)
79-
98+
destname = base_name + b' ' + text_type(counter).encode('ascii') + ext
99+
80100
check_create(filespath)
81101
check_create(infopath)
82-
102+
83103
os.rename(src, op.join(filespath, destname))
84104
f = open(op.join(infopath, destname + INFO_SUFFIX), 'w')
85105
f.write(info_for(src, topdir))
@@ -99,14 +119,14 @@ def find_ext_volume_global_trash(volume_root):
99119
trash_dir = op.join(volume_root, TOPDIR_TRASH)
100120
if not op.exists(trash_dir):
101121
return None
102-
122+
103123
mode = os.lstat(trash_dir).st_mode
104124
# vol/.Trash must be a directory, cannot be a symlink, and must have the
105125
# sticky bit set.
106126
if not op.isdir(trash_dir) or op.islink(trash_dir) or not (mode & stat.S_ISVTX):
107127
return None
108128

109-
trash_dir = op.join(trash_dir, str(uid))
129+
trash_dir = op.join(trash_dir, text_type(uid).encode('ascii'))
110130
try:
111131
check_create(trash_dir)
112132
except OSError:
@@ -116,9 +136,13 @@ def find_ext_volume_global_trash(volume_root):
116136
def find_ext_volume_fallback_trash(volume_root):
117137
# from [2] Trash directories (1) create a .Trash-$uid dir.
118138
trash_dir = op.join(volume_root, TOPDIR_FALLBACK)
119-
# Try to make the directory, if we can't the OSError exception will escape
120-
# be thrown out of send2trash.
121-
check_create(trash_dir)
139+
# Try to make the directory, if we lack permission, raise TrashPermissionError
140+
try:
141+
check_create(trash_dir)
142+
except OSError as e:
143+
if e.errno == errno.EACCES:
144+
raise TrashPermissionError(e.filename)
145+
raise
122146
return trash_dir
123147

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

134158
def send2trash(path):
135-
if not isinstance(path, str):
136-
# path = str(path, sys.getfilesystemencoding()) # removed invalid arg passed to str function, shouldn't be used anyway
137-
path = str(path)
159+
if isinstance(path, text_type):
160+
path_b = fsencode(path)
161+
elif isinstance(path, bytes):
162+
path_b = path
163+
elif hasattr(path, '__fspath__'):
164+
# Python 3.6 PathLike protocol
165+
return send2trash(path.__fspath__())
166+
else:
167+
raise TypeError('str, bytes or PathLike expected, not %r' % type(path))
138168

139-
if not op.exists(path):
169+
if not op.exists(path_b):
140170
raise OSError("File not found: %s" % path)
141171
# ...should check whether the user has the necessary permissions to delete
142172
# it, before starting the trashing operation itself. [2]
143-
if not os.access(path, os.W_OK):
173+
if not os.access(path_b, os.W_OK):
144174
raise OSError("Permission denied: %s" % path)
145175
# if the file to be trashed is on the same device as HOMETRASH we
146176
# want to move it there.
147-
path_dev = get_dev(path)
148-
177+
path_dev = get_dev(path_b)
178+
149179
# If XDG_DATA_HOME or HOMETRASH do not yet exist we need to stat the
150180
# home directory, and these paths will be created further on if needed.
151-
trash_dev = get_dev(op.expanduser('~'))
181+
trash_dev = get_dev(op.expanduser(b'~'))
152182

153183
if path_dev == trash_dev:
154184
topdir = XDG_DATA_HOME
155-
dest_trash = HOMETRASH
185+
dest_trash = HOMETRASH_B
156186
else:
157-
topdir = find_mount_point(path)
187+
topdir = find_mount_point(path_b)
158188
trash_dev = get_dev(topdir)
159189
if trash_dev != path_dev:
160190
raise OSError("Couldn't find mount point for %s" % path)
161191
dest_trash = find_ext_volume_trash(topdir)
162-
trash_move(path, dest_trash, topdir)
192+
trash_move(path_b, dest_trash, topdir)

lib/send2trash/plat_win.py

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,26 @@
1-
# Copyright 2013 Hardcoded Software (http://www.hardcoded.net)
1+
# Copyright 2017 Virgil Dupras
22

3-
# This software is licensed under the "BSD" License as described in the "LICENSE" file,
4-
# which should be included with this package. The terms are also available at
3+
# This software is licensed under the "BSD" License as described in the "LICENSE" file,
4+
# which should be included with this package. The terms are also available at
55
# http://www.hardcoded.net/licenses/bsd_license
66

77
from __future__ import unicode_literals
88

9-
from ctypes import windll, Structure, byref, c_uint
9+
from ctypes import (windll, Structure, byref, c_uint,
10+
create_unicode_buffer, addressof,
11+
GetLastError, FormatError)
1012
from ctypes.wintypes import HWND, UINT, LPCWSTR, BOOL
1113
import os.path as op
1214

1315
from .compat import text_type
1416

17+
kernel32 = windll.kernel32
18+
GetShortPathNameW = kernel32.GetShortPathNameW
19+
1520
shell32 = windll.shell32
1621
SHFileOperationW = shell32.SHFileOperationW
1722

23+
1824
class SHFILEOPSTRUCTW(Structure):
1925
_fields_ = [
2026
("hwnd", HWND),
@@ -27,6 +33,7 @@ class SHFILEOPSTRUCTW(Structure):
2733
("lpszProgressTitle", LPCWSTR),
2834
]
2935

36+
3037
FO_MOVE = 1
3138
FO_COPY = 2
3239
FO_DELETE = 3
@@ -38,22 +45,48 @@ class SHFILEOPSTRUCTW(Structure):
3845
FOF_ALLOWUNDO = 64
3946
FOF_NOERRORUI = 1024
4047

48+
49+
def get_short_path_name(long_name):
50+
if not long_name.startswith('\\\\?\\'):
51+
long_name = '\\\\?\\' + long_name
52+
buf_size = GetShortPathNameW(long_name, None, 0)
53+
# FIX: https://github.com/hsoft/send2trash/issues/31
54+
# If buffer size is zero, an error has occurred.
55+
if not buf_size:
56+
err_no = GetLastError()
57+
raise WindowsError(err_no, FormatError(err_no), long_name[4:])
58+
output = create_unicode_buffer(buf_size)
59+
GetShortPathNameW(long_name, output, buf_size)
60+
return output.value[4:] # Remove '\\?\' for SHFileOperationW
61+
62+
4163
def send2trash(path):
4264
if not isinstance(path, text_type):
4365
path = text_type(path, 'mbcs')
4466
if not op.isabs(path):
4567
path = op.abspath(path)
68+
path = get_short_path_name(path)
4669
fileop = SHFILEOPSTRUCTW()
4770
fileop.hwnd = 0
4871
fileop.wFunc = FO_DELETE
49-
fileop.pFrom = LPCWSTR(path + '\0')
72+
# FIX: https://github.com/hsoft/send2trash/issues/17
73+
# Starting in python 3.6.3 it is no longer possible to use:
74+
# LPCWSTR(path + '\0') directly as embedded null characters are no longer
75+
# allowed in strings
76+
# Workaround
77+
# - create buffer of c_wchar[] (LPCWSTR is based on this type)
78+
# - buffer is two c_wchar characters longer (double null terminator)
79+
# - cast the address of the buffer to a LPCWSTR
80+
# NOTE: based on how python allocates memory for these types they should
81+
# always be zero, if this is ever not true we can go back to explicitly
82+
# setting the last two characters to null using buffer[index] = '\0'.
83+
buffer = create_unicode_buffer(path, len(path)+2)
84+
fileop.pFrom = LPCWSTR(addressof(buffer))
5085
fileop.pTo = None
5186
fileop.fFlags = FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_NOERRORUI | FOF_SILENT
5287
fileop.fAnyOperationsAborted = 0
5388
fileop.hNameMappings = 0
5489
fileop.lpszProgressTitle = None
5590
result = SHFileOperationW(byref(fileop))
5691
if result:
57-
msg = "Couldn't perform operation. Error code: %d" % result
58-
raise OSError(msg)
59-
92+
raise WindowsError(result, FormatError(result), path)

0 commit comments

Comments
 (0)