mirror of
https://github.com/arsenetar/send2trash.git
synced 2026-01-25 16:11:39 +00:00
Compare commits
108 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
0244f53e2e
|
|||
|
1625d56345
|
|||
| 63e770d29e | |||
|
bfd3e08661
|
|||
| 490fe02245 | |||
| d6d904c774 | |||
|
|
0a36688a4b | ||
|
|
955f8091da | ||
|
0ef9b3294a
|
|||
|
4b9bc4bc31
|
|||
|
be402728fb
|
|||
|
448224954b
|
|||
|
d37197c4f7
|
|||
|
2a88b82104
|
|||
|
18e51c0b5a
|
|||
|
7686647389
|
|||
|
696aed558b
|
|||
|
007d84361a
|
|||
|
78a536abba
|
|||
|
2aa834be94
|
|||
|
5e4517aa53
|
|||
|
62849fba0b
|
|||
|
74f2dff57b
|
|||
|
922fc0342a
|
|||
| 1d1b8755a9 | |||
|
d0e4890a4d
|
|||
|
24079e245c
|
|||
|
24b38e4ffe
|
|||
|
69a82a5162
|
|||
|
2b3f9891c2
|
|||
|
039f92264b
|
|||
|
c2c47610c8
|
|||
|
66fc79695e
|
|||
|
454ebeb072
|
|||
|
7ca68e5473
|
|||
|
484913ba0f
|
|||
|
d249f0106b
|
|||
|
94e1ec007a
|
|||
|
84c220cbd9
|
|||
|
6612545110
|
|||
|
d52b4f206c
|
|||
|
33171dde82
|
|||
| 077598d2ce | |||
|
|
436686bf0f | ||
|
23ce7b8c16
|
|||
|
9b0d5796c1
|
|||
|
c8bcaea1e8
|
|||
| 530e9b4bc6 | |||
|
10c7693d11
|
|||
|
356509120b
|
|||
|
f9fcdb8d8c
|
|||
|
af0c1ba704
|
|||
|
37be84d46e
|
|||
|
9f76fbf036
|
|||
|
a324923ffa
|
|||
|
dbdcce8b04
|
|||
|
054d56c564
|
|||
|
33ed07811b
|
|||
| 5d3835735e | |||
|
|
741c7ad51f | ||
| 2eb3242cd9 | |||
| 60bcb2c834 | |||
| c411f4eae4 | |||
| f64c69f905 | |||
|
00dfe77e40
|
|||
| 16a7115ff1 | |||
| ec73b44c43 | |||
|
|
f62b4f1ffd | ||
|
|
38ae2b63d2 | ||
|
|
cd8d9fb95e | ||
|
|
20bbab0b4c | ||
|
49bc438546
|
|||
|
2e9fa38f56
|
|||
|
1e099724c5
|
|||
|
6ac20bc4f6
|
|||
|
e3d2be3243
|
|||
| d078554052 | |||
|
|
9ede898c3e | ||
|
|
66afce7252 | ||
|
|
8f684a9c8b | ||
|
|
1c32d471f2 | ||
|
|
74352462f5 | ||
|
|
0d7b4b4ad9 | ||
|
|
1dded4f572 | ||
|
|
020d05979d | ||
|
|
6b0bd46036 | ||
|
|
f6897609ba | ||
|
|
7d38de269a | ||
| 5733670fc2 | |||
|
|
22ed5dc09b | ||
|
|
3071684f73 | ||
|
|
75a9cd6b55 | ||
|
|
f38aec6569 | ||
|
|
072e28cb1e | ||
|
|
016b90c1ac | ||
|
|
f324ff491e | ||
|
|
f3231ef857 | ||
|
|
b7e3057853 | ||
|
|
7fece243d8 | ||
|
|
4181ed65e9 | ||
|
|
dd69edad3b | ||
|
|
bd9183afe9 | ||
|
|
f6f63b1796 | ||
|
|
0974912e78 | ||
|
|
6c01453fd3 | ||
|
|
7cbefa4317 | ||
|
|
72bc94b48d | ||
|
|
35ad95bcd5 |
71
.github/workflows/codeql-analysis.yml
vendored
Normal file
71
.github/workflows/codeql-analysis.yml
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
# For most projects, this workflow file will not need changing; you simply need
|
||||
# to commit it to your repository.
|
||||
#
|
||||
# You may wish to alter this file to override the set of languages analyzed,
|
||||
# or to provide custom queries or build logic.
|
||||
#
|
||||
# ******** NOTE ********
|
||||
# We have attempted to detect the languages in your repository. Please check
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [master]
|
||||
schedule:
|
||||
- cron: "25 5 * * 4"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: ["python"]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
|
||||
# Learn more:
|
||||
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v2
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
82
.github/workflows/default.yml
vendored
Normal file
82
.github/workflows/default.yml
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
# Workflow lints, and checks format in parallel then runs tests on all platforms
|
||||
|
||||
name: Default CI/CD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python 3.x
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: 3.x
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install flake8
|
||||
- name: Lint with flake8
|
||||
run: |
|
||||
flake8 .
|
||||
test:
|
||||
needs: lint
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
python-version: 3.11
|
||||
- os: ubuntu-latest
|
||||
python-version: "3.10"
|
||||
- os: ubuntu-latest
|
||||
python-version: 3.9
|
||||
- os: ubuntu-latest
|
||||
python-version: 3.8
|
||||
- os: ubuntu-latest
|
||||
python-version: 3.7
|
||||
- os: ubuntu-20.04
|
||||
python-version: 3.6
|
||||
- os: ubuntu-20.04
|
||||
python-version: 3.5
|
||||
- os: ubuntu-latest
|
||||
python-version: 2.7
|
||||
# - os: macos-latest
|
||||
# python-version: 3.11
|
||||
# - os: macos-latest
|
||||
# python-version: 3.8
|
||||
# - os: macos-latest
|
||||
# python-version: 2.7
|
||||
- os: windows-latest
|
||||
python-version: 3.11
|
||||
- os: windows-latest
|
||||
python-version: 3.8
|
||||
- os: windows-latest
|
||||
python-version: 2.7
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v3
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install pytest
|
||||
- name: Install windows dependencies
|
||||
if: ${{ matrix.os == 'windows-latest' }}
|
||||
run: |
|
||||
pip install pywin32
|
||||
- name: Install macOS dependencies
|
||||
if: ${{ matrix.os == 'macos-latest' }}
|
||||
run: |
|
||||
pip install pyobjc-framework-Cocoa
|
||||
- name: Run tests
|
||||
run: |
|
||||
pytest
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -2,4 +2,7 @@
|
||||
*.egg-info
|
||||
/build
|
||||
/dist
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
/.tox
|
||||
__pycache__
|
||||
/env
|
||||
79
CHANGES.rst
79
CHANGES.rst
@@ -1,11 +1,86 @@
|
||||
Changes
|
||||
=======
|
||||
|
||||
Version 1.8.2 -- 2023/04/27
|
||||
---------------------------
|
||||
* win/legacy: tiny logic simplification by @BoboTiG in https://github.com/arsenetar/send2trash/pull/77
|
||||
* TravisCI is not used anymore by @sobolevn in https://github.com/arsenetar/send2trash/pull/73
|
||||
* fix(ci): Update to latest actions, fix python versions by @arsenetar in https://github.com/arsenetar/send2trash/pull/78
|
||||
|
||||
Version 1.8.1b0 -- 2021/09/20
|
||||
-----------------------------
|
||||
* Add fallback to HOMETRASH when cross device errors happen in plat_other (#26, #41, #63)
|
||||
|
||||
Version 1.8.0 -- 2021/08/08
|
||||
---------------------------
|
||||
|
||||
* Add compatibility with pathlib paths (#49)
|
||||
* Fix thread compatibility of modern windows implementation (#59)
|
||||
* Fix handling of UNC names in legacy windows implementation (#57)
|
||||
|
||||
Version 1.7.1 -- 2021/06/21
|
||||
---------------------------
|
||||
|
||||
* Release stable version with changes from last 3 releases
|
||||
* Fix handling of UNC names (#57)
|
||||
|
||||
Version 1.7.0a1 -- 2021/05/14
|
||||
-----------------------------
|
||||
|
||||
* Changed conditional for when to try to use pyobjc version (#51)
|
||||
|
||||
Version 1.7.0a0 -- 2021/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
|
||||
-----------------------------
|
||||
|
||||
* Add main method which allows calling via ``python -m send2trash somefile``
|
||||
* Windows: Add support for using IFileOperation when pywin32 is present on Vista and newer
|
||||
* Add support for passing multiple files at once in a list
|
||||
* Windows: Batch multi-file calls to improve performance (#42)
|
||||
* Windows: Fix issue with SHFileOperation failing silently when path is not found (#33)
|
||||
|
||||
Version 1.5.0 -- 2018/02/16
|
||||
---------------------------
|
||||
|
||||
* More specific error when failing to create XDG fallback trash directory (#20)
|
||||
* Windows: Workaround for long paths (#23)
|
||||
|
||||
Version 1.4.2 -- 2017/11/17
|
||||
---------------------------
|
||||
|
||||
* Fix incompatibility with Python 3.6 on Windows. (#18)
|
||||
|
||||
Version 1.4.1 -- 2017/08/07
|
||||
---------------------------
|
||||
|
||||
* Fix crash on Windows introduced in v1.4.0. Oops... (#14)
|
||||
|
||||
Version 1.4.0 -- 2017/08/07
|
||||
---------------------------
|
||||
|
||||
* Use ``bytes`` instead of ``str`` for internal path handling in ``plat_other``. (#13)
|
||||
|
||||
Version 1.3.1 -- 2017/07/31
|
||||
---------------------------
|
||||
|
||||
* Throw ``WindowsError`` instead of ``OSError`` in ``plat_win``. (#7)
|
||||
* Fix ``TypeError`` on python 2 in ``plat_other``. (#12)
|
||||
|
||||
Version 1.3.0 -- 2013/07/19
|
||||
---------------------------
|
||||
|
||||
* Added support for Gnome's GIO.
|
||||
* Merged Python 3 and Python 2 vesion in a single codebase.
|
||||
* Merged Python 3 and Python 2 versions in a single codebase.
|
||||
|
||||
Version 1.2.0 -- 2011/03/16
|
||||
---------------------------
|
||||
@@ -30,4 +105,4 @@ Version 1.0.1 -- 2010/04/19
|
||||
Version 1.0.0 -- 2010/04/07
|
||||
---------------------------
|
||||
|
||||
* Initial Release
|
||||
* Initial Release
|
||||
|
||||
4
LICENSE
4
LICENSE
@@ -1,4 +1,4 @@
|
||||
Copyright (c) 2013, Hardcoded Software, http://www.hardcoded.net
|
||||
Copyright (c) 2017, Virgil Dupras
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
|
||||
@@ -7,4 +7,4 @@ Redistribution and use in source and binary forms, with or without modification,
|
||||
* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
* Neither the name of Hardcoded Software Inc. nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
@@ -1 +1 @@
|
||||
include CHANGES.rst
|
||||
include CHANGES.rst LICENSE
|
||||
|
||||
36
README.rst
36
README.rst
@@ -3,23 +3,34 @@ Send2Trash -- Send files to trash on all platforms
|
||||
==================================================
|
||||
|
||||
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, if `PyGObject`_ and
|
||||
`GIO`_ are available, it will use this. Otherwise, it will fallback to its own implementation
|
||||
of the `trash specifications from freedesktop.org`_.
|
||||
*all platforms*. On OS X, it uses native ``FSMoveObjectToTrashSync`` Cocoa calls or can use pyobjc
|
||||
with NSFileManager. On Windows, it uses native ``IFileOperation`` call if on Vista or newer and
|
||||
pywin32 is installed or falls back to ``SHFileOperation`` calls. On other platforms, if `PyGObject`_
|
||||
and `GIO`_ are available, it will use this. Otherwise, it will fallback to its own implementation of
|
||||
the `trash specifications from freedesktop.org`_.
|
||||
|
||||
``ctypes`` is used to access native libraries, so no compilation is necessary.
|
||||
|
||||
Send2Trash supports Python 2.7 and up (Python 3 is supported).
|
||||
|
||||
Status: Additional Help Welcome
|
||||
-------------------------------
|
||||
|
||||
Additional help is welcome for supporting this package. Specifically help with the OSX and Linux
|
||||
issues and fixes would be most appreciated.
|
||||
|
||||
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/hsoft/send2trash and install it with::
|
||||
To install with pywin32 or pyobjc required specify the extra `nativeLib`:
|
||||
|
||||
python -m pip install -U send2trash[nativeLib]
|
||||
|
||||
or you can download the source from http://github.com/arsenetar/send2trash and install it with::
|
||||
|
||||
>>> python setup.py install
|
||||
|
||||
@@ -28,8 +39,17 @@ Usage
|
||||
|
||||
>>> from send2trash import send2trash
|
||||
>>> send2trash('some_file')
|
||||
>>> send2trash(['some_file1', 'some_file2'])
|
||||
|
||||
When there's a problem ``OSError`` is raised.
|
||||
On Freedesktop platforms (Linux, BSD, etc.), you may not be able to efficiently
|
||||
trash some files. In these cases, an exception ``send2trash.TrashPermissionError``
|
||||
is raised, so that the application can handle this case. This inherits from
|
||||
``PermissionError`` (``OSError`` on Python 2). Specifically, this affects
|
||||
files on a different device to the user's home directory, where the root of the
|
||||
device does not have a ``.Trash`` directory, and we don't have permission to
|
||||
create a ``.Trash-$UID`` directory.
|
||||
|
||||
For any other problem, ``OSError`` is raised.
|
||||
|
||||
.. _PyGObject: https://wiki.gnome.org/PyGObject
|
||||
.. _GIO: https://developer.gnome.org/gio/
|
||||
|
||||
6
pyproject.toml
Normal file
6
pyproject.toml
Normal file
@@ -0,0 +1,6 @@
|
||||
[build-system]
|
||||
requires = ["setuptools >= 40.6.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.black]
|
||||
line-length = 120
|
||||
@@ -1,19 +1,21 @@
|
||||
# Copyright 2013 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
# 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.platform == 'darwin':
|
||||
from .plat_osx import send2trash
|
||||
elif sys.platform == 'win32':
|
||||
from .plat_win import send2trash
|
||||
from send2trash.exceptions import TrashPermissionError # noqa: F401
|
||||
|
||||
if sys.platform == "darwin":
|
||||
from send2trash.mac import send2trash
|
||||
elif sys.platform == "win32":
|
||||
from send2trash.win import send2trash
|
||||
else:
|
||||
try:
|
||||
# If we can use gio, let's use it
|
||||
from .plat_gio import send2trash
|
||||
from send2trash.plat_gio import send2trash
|
||||
except ImportError:
|
||||
# Oh well, let's fallback to our own Freedesktop trash implementation
|
||||
from .plat_other import send2trash
|
||||
from send2trash.plat_other import send2trash # noqa: F401
|
||||
|
||||
33
send2trash/__main__.py
Normal file
33
send2trash/__main__.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# encoding: utf-8
|
||||
# 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 print_function
|
||||
|
||||
import sys
|
||||
|
||||
from argparse import ArgumentParser
|
||||
from send2trash import send2trash
|
||||
|
||||
|
||||
def main(args=None):
|
||||
parser = ArgumentParser(description="Tool to send files to trash")
|
||||
parser.add_argument("files", nargs="+")
|
||||
parser.add_argument("-v", "--verbose", action="store_true", help="Print deleted files")
|
||||
args = parser.parse_args(args)
|
||||
|
||||
for filename in args.files:
|
||||
try:
|
||||
send2trash(filename)
|
||||
if args.verbose:
|
||||
print("Trashed «" + filename + "»")
|
||||
except OSError as e:
|
||||
print(str(e), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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 # noqa: F821
|
||||
binary_type = str
|
||||
environb = os.environ
|
||||
|
||||
26
send2trash/exceptions.py
Normal file
26
send2trash/exceptions.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import errno
|
||||
from send2trash.compat import PY3
|
||||
|
||||
if PY3:
|
||||
_permission_error = PermissionError # noqa: F821
|
||||
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)
|
||||
20
send2trash/mac/__init__.py
Normal file
20
send2trash/mac/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# 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 platform import mac_ver
|
||||
from sys import version_info
|
||||
|
||||
# NOTE: version of pyobjc only supports python >= 3.6 and 10.9+
|
||||
macos_ver = tuple(int(part) for part in mac_ver()[0].split("."))
|
||||
if version_info >= (3, 6) and macos_ver >= (10, 9):
|
||||
try:
|
||||
from send2trash.mac.modern import send2trash
|
||||
except ImportError:
|
||||
# Try to fall back to ctypes version, although likely problematic still
|
||||
from send2trash.mac.legacy import send2trash
|
||||
else:
|
||||
# Just use the old version otherwise
|
||||
from send2trash.mac.legacy import send2trash # noqa: F401
|
||||
@@ -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
|
||||
@@ -9,10 +9,11 @@ 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
|
||||
from send2trash.compat import binary_type
|
||||
from send2trash.util import preprocess_paths
|
||||
|
||||
Foundation = cdll.LoadLibrary(find_library('Foundation'))
|
||||
CoreServices = cdll.LoadLibrary(find_library('CoreServices'))
|
||||
Foundation = cdll.LoadLibrary(find_library("Foundation"))
|
||||
CoreServices = cdll.LoadLibrary(find_library("CoreServices"))
|
||||
|
||||
GetMacOSStatusCommentString = Foundation.GetMacOSStatusCommentString
|
||||
GetMacOSStatusCommentString.restype = c_char_p
|
||||
@@ -28,21 +29,25 @@ kFSFileOperationSkipSourcePermissionErrors = 0x02
|
||||
kFSFileOperationDoNotMoveAcrossVolumes = 0x04
|
||||
kFSFileOperationSkipPreflight = 0x08
|
||||
|
||||
|
||||
class FSRef(Structure):
|
||||
_fields_ = [('hidden', c_char * 80)]
|
||||
_fields_ = [("hidden", c_char * 80)]
|
||||
|
||||
|
||||
def check_op_result(op_result):
|
||||
if op_result:
|
||||
msg = GetMacOSStatusCommentString(op_result).decode('utf-8')
|
||||
msg = GetMacOSStatusCommentString(op_result).decode("utf-8")
|
||||
raise OSError(msg)
|
||||
|
||||
def send2trash(path):
|
||||
if not isinstance(path, binary_type):
|
||||
path = path.encode('utf-8')
|
||||
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)
|
||||
|
||||
def send2trash(paths):
|
||||
paths = preprocess_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)
|
||||
26
send2trash/mac/modern.py
Normal file
26
send2trash/mac/modern.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# 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 send2trash.compat import text_type
|
||||
from send2trash.util import preprocess_paths
|
||||
|
||||
|
||||
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):
|
||||
paths = preprocess_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)
|
||||
@@ -1,14 +1,23 @@
|
||||
# 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 send2trash.exceptions import TrashPermissionError
|
||||
from send2trash.util import preprocess_paths
|
||||
|
||||
def send2trash(path):
|
||||
try:
|
||||
f = Gio.File.new_for_path(path)
|
||||
f.trash(cancellable=None)
|
||||
except GObject.GError as e:
|
||||
raise OSError(e.message)
|
||||
|
||||
def send2trash(paths):
|
||||
paths = preprocess_paths(paths)
|
||||
for path in paths:
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
@@ -16,56 +16,87 @@
|
||||
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import errno
|
||||
import sys
|
||||
import os
|
||||
import shutil
|
||||
import os.path as op
|
||||
from datetime import datetime
|
||||
import stat
|
||||
|
||||
try:
|
||||
from urllib.parse import quote
|
||||
except ImportError:
|
||||
# Python 2
|
||||
from urllib import quote
|
||||
|
||||
FILES_DIR = 'files'
|
||||
INFO_DIR = 'info'
|
||||
INFO_SUFFIX = '.trashinfo'
|
||||
from send2trash.compat import text_type, environb
|
||||
from send2trash.util import preprocess_paths
|
||||
from send2trash.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
|
||||
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)
|
||||
else:
|
||||
src = op.relpath(src, topdir)
|
||||
|
||||
info = "[Trash Info]\n"
|
||||
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):
|
||||
|
||||
def trash_move(src, dst, topdir=None, cross_dev=False):
|
||||
filename = op.basename(src)
|
||||
filespath = op.join(dst, FILES_DIR)
|
||||
infopath = op.join(dst, INFO_DIR)
|
||||
@@ -75,86 +106,113 @@ 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))
|
||||
f.close()
|
||||
|
||||
with open(op.join(infopath, destname + INFO_SUFFIX), "w") as f:
|
||||
f.write(info_for(src, topdir))
|
||||
destpath = op.join(filespath, destname)
|
||||
if cross_dev:
|
||||
shutil.move(src, destpath)
|
||||
else:
|
||||
os.rename(src, destpath)
|
||||
|
||||
|
||||
def find_mount_point(path):
|
||||
# Even if something's wrong, "/" is a mount point, so the loop will exit.
|
||||
# Use realpath in case it's a symlink
|
||||
path = op.realpath(path) # Required to avoid infinite loop
|
||||
while not op.ismount(path):
|
||||
path = op.realpath(path) # Required to avoid infinite loop
|
||||
while not op.ismount(path): # Note ismount() does not always detect mounts
|
||||
path = op.split(path)[0]
|
||||
return path
|
||||
|
||||
|
||||
def find_ext_volume_global_trash(volume_root):
|
||||
# from [2] Trash directories (1) check for a .Trash dir with the right
|
||||
# permissions set.
|
||||
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:
|
||||
return None
|
||||
return trash_dir
|
||||
|
||||
|
||||
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):
|
||||
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):
|
||||
if not isinstance(path, str):
|
||||
path = str(path, sys.getfilesystemencoding())
|
||||
if not op.exists(path):
|
||||
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):
|
||||
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)
|
||||
|
||||
# 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)
|
||||
def send2trash(paths):
|
||||
paths = preprocess_paths(paths)
|
||||
for path in paths:
|
||||
if isinstance(path, text_type):
|
||||
path_b = fsencode(path)
|
||||
elif isinstance(path, bytes):
|
||||
path_b = path
|
||||
else:
|
||||
raise TypeError("str, bytes or PathLike expected, not %r" % type(path))
|
||||
|
||||
if not op.exists(path_b):
|
||||
raise OSError(errno.ENOENT, "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_b, os.W_OK):
|
||||
raise OSError(errno.EACCES, "Permission denied: %s" % 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(b"~"))
|
||||
|
||||
# if the file to be trashed is on the same device as HOMETRASH we
|
||||
# want to move it there.
|
||||
if path_dev == trash_dev:
|
||||
topdir = XDG_DATA_HOME
|
||||
dest_trash = HOMETRASH_B
|
||||
else:
|
||||
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)
|
||||
try:
|
||||
trash_move(path_b, dest_trash, topdir)
|
||||
except OSError as error:
|
||||
# Cross link errors default back to HOMETRASH
|
||||
if error.errno == errno.EXDEV:
|
||||
trash_move(path_b, HOMETRASH_B, XDG_DATA_HOME, cross_dev=True)
|
||||
else:
|
||||
raise
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
# Copyright 2013 Hardcoded Software (http://www.hardcoded.net)
|
||||
|
||||
# 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.wintypes import HWND, UINT, LPCWSTR, BOOL
|
||||
import os.path as op
|
||||
|
||||
from .compat import text_type
|
||||
|
||||
shell32 = windll.shell32
|
||||
SHFileOperationW = shell32.SHFileOperationW
|
||||
|
||||
class SHFILEOPSTRUCTW(Structure):
|
||||
_fields_ = [
|
||||
("hwnd", HWND),
|
||||
("wFunc", UINT),
|
||||
("pFrom", LPCWSTR),
|
||||
("pTo", LPCWSTR),
|
||||
("fFlags", c_uint),
|
||||
("fAnyOperationsAborted", BOOL),
|
||||
("hNameMappings", c_uint),
|
||||
("lpszProgressTitle", LPCWSTR),
|
||||
]
|
||||
|
||||
FO_MOVE = 1
|
||||
FO_COPY = 2
|
||||
FO_DELETE = 3
|
||||
FO_RENAME = 4
|
||||
|
||||
FOF_MULTIDESTFILES = 1
|
||||
FOF_SILENT = 4
|
||||
FOF_NOCONFIRMATION = 16
|
||||
FOF_ALLOWUNDO = 64
|
||||
FOF_NOERRORUI = 1024
|
||||
|
||||
def send2trash(path):
|
||||
if not isinstance(path, text_type):
|
||||
path = text_type(path, 'mbcs')
|
||||
if not op.isabs(path):
|
||||
path = op.abspath(path)
|
||||
fileop = SHFILEOPSTRUCTW()
|
||||
fileop.hwnd = 0
|
||||
fileop.wFunc = FO_DELETE
|
||||
fileop.pFrom = LPCWSTR(path + '\0')
|
||||
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)
|
||||
|
||||
14
send2trash/util.py
Normal file
14
send2trash/util.py
Normal file
@@ -0,0 +1,14 @@
|
||||
# encoding: utf-8
|
||||
# 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
|
||||
|
||||
|
||||
def preprocess_paths(paths):
|
||||
if not isinstance(paths, list):
|
||||
paths = [paths]
|
||||
# Convert items such as pathlib paths to strings
|
||||
paths = [path.__fspath__() if hasattr(path, "__fspath__") else path for path in paths]
|
||||
return paths
|
||||
46
send2trash/win/IFileOperationProgressSink.py
Normal file
46
send2trash/win/IFileOperationProgressSink.py
Normal file
@@ -0,0 +1,46 @@
|
||||
# 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, hr_delete, newly_created):
|
||||
if newly_created:
|
||||
self.newItem = newly_created.GetDisplayName(shellcon.SHGDN_FORPARSING)
|
||||
|
||||
|
||||
def create_sink():
|
||||
return pythoncom.WrapObject(FileOperationProgressSink(), shell.IID_IFileOperationProgressSink)
|
||||
20
send2trash/win/__init__.py
Normal file
20
send2trash/win/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# 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 platform import version
|
||||
|
||||
# if windows is vista or newer and pywin32 is available use IFileOperation
|
||||
if int(version().split(".", 1)[0]) >= 6:
|
||||
try:
|
||||
# Attempt to use pywin32 to use IFileOperation
|
||||
from send2trash.win.modern import send2trash
|
||||
except ImportError:
|
||||
# use SHFileOperation as fallback
|
||||
from send2trash.win.legacy import send2trash
|
||||
else:
|
||||
# use SHFileOperation as fallback
|
||||
from send2trash.win.legacy import send2trash # noqa: F401
|
||||
183
send2trash/win/legacy.py
Normal file
183
send2trash/win/legacy.py
Normal file
@@ -0,0 +1,183 @@
|
||||
# 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
|
||||
import os.path as op
|
||||
|
||||
from send2trash.compat import text_type
|
||||
from send2trash.util import preprocess_paths
|
||||
|
||||
from ctypes import (
|
||||
windll,
|
||||
Structure,
|
||||
byref,
|
||||
c_uint,
|
||||
create_unicode_buffer,
|
||||
addressof,
|
||||
GetLastError,
|
||||
FormatError,
|
||||
)
|
||||
from ctypes.wintypes import HWND, UINT, LPCWSTR, BOOL
|
||||
|
||||
kernel32 = windll.kernel32
|
||||
GetShortPathNameW = kernel32.GetShortPathNameW
|
||||
|
||||
shell32 = windll.shell32
|
||||
SHFileOperationW = shell32.SHFileOperationW
|
||||
|
||||
|
||||
class SHFILEOPSTRUCTW(Structure):
|
||||
_fields_ = [
|
||||
("hwnd", HWND),
|
||||
("wFunc", UINT),
|
||||
("pFrom", LPCWSTR),
|
||||
("pTo", LPCWSTR),
|
||||
("fFlags", c_uint),
|
||||
("fAnyOperationsAborted", BOOL),
|
||||
("hNameMappings", c_uint),
|
||||
("lpszProgressTitle", LPCWSTR),
|
||||
]
|
||||
|
||||
|
||||
FO_MOVE = 1
|
||||
FO_COPY = 2
|
||||
FO_DELETE = 3
|
||||
FO_RENAME = 4
|
||||
|
||||
FOF_MULTIDESTFILES = 1
|
||||
FOF_SILENT = 4
|
||||
FOF_NOCONFIRMATION = 16
|
||||
FOF_ALLOWUNDO = 64
|
||||
FOF_NOERRORUI = 1024
|
||||
|
||||
|
||||
def convert_sh_file_opt_result(result):
|
||||
# map overlapping values from SHFileOpterationW to approximate standard windows errors
|
||||
# ref https://docs.microsoft.com/en-us/windows/win32/api/shellapi/nf-shellapi-shfileoperationw#return-value
|
||||
# ref https://docs.microsoft.com/en-us/windows/win32/debug/system-error-codes--0-499-
|
||||
results = {
|
||||
0x71: 0x50, # DE_SAMEFILE -> ERROR_FILE_EXISTS
|
||||
0x72: 0x57, # DE_MANYSRC1DEST -> ERROR_INVALID_PARAMETER
|
||||
0x73: 0x57, # DE_DIFFDIR -> ERROR_INVALID_PARAMETER
|
||||
0x74: 0x57, # DE_ROOTDIR -> ERROR_INVALID_PARAMETER
|
||||
0x75: 0x4C7, # DE_OPCANCELLED -> ERROR_CANCELLED
|
||||
0x76: 0x57, # DE_DESTSUBTREE -> ERROR_INVALID_PARAMETER
|
||||
0x78: 0x05, # DE_ACCESSDENIEDSRC -> ERROR_ACCESS_DENIED
|
||||
0x79: 0x6F, # DE_PATHTOODEEP -> ERROR_BUFFER_OVERFLOW
|
||||
0x7A: 0x57, # DE_MANYDEST -> ERROR_INVALID_PARAMETER
|
||||
0x7C: 0xA1, # DE_INVALIDFILES -> ERROR_BAD_PATHNAME
|
||||
0x7D: 0x57, # DE_DESTSAMETREE -> ERROR_INVALID_PARAMETER
|
||||
0x7E: 0xB7, # DE_FLDDESTISFILE -> ERROR_ALREADY_EXISTS
|
||||
0x80: 0xB7, # DE_FILEDESTISFLD -> ERROR_ALREADY_EXISTS
|
||||
0x81: 0x6F, # DE_FILENAMETOOLONG -> ERROR_BUFFER_OVERFLOW
|
||||
0x82: 0x13, # DE_DEST_IS_CDROM -> ERROR_WRITE_PROTECT
|
||||
0x83: 0x13, # DE_DEST_IS_DVD -> ERROR_WRITE_PROTECT
|
||||
0x84: 0x6F9, # DE_DEST_IS_CDRECORD -> ERROR_UNRECOGNIZED_MEDIA
|
||||
0x85: 0xDF, # DE_FILE_TOO_LARGE -> ERROR_FILE_TOO_LARGE
|
||||
0x86: 0x13, # DE_SRC_IS_CDROM -> ERROR_WRITE_PROTECT
|
||||
0x87: 0x13, # DE_SRC_IS_DVD -> ERROR_WRITE_PROTECT
|
||||
0x88: 0x6F9, # DE_SRC_IS_CDRECORD -> ERROR_UNRECOGNIZED_MEDIA
|
||||
0xB7: 0x6F, # DE_ERROR_MAX -> ERROR_BUFFER_OVERFLOW
|
||||
0x402: 0xA1, # UNKNOWN -> ERROR_BAD_PATHNAME
|
||||
0x10000: 0x1D, # ERRORONDEST -> ERROR_WRITE_FAULT
|
||||
0x10074: 0x57, # DE_ROOTDIR | ERRORONDEST -> ERROR_INVALID_PARAMETER
|
||||
}
|
||||
|
||||
return results.get(result, result)
|
||||
|
||||
|
||||
def prefix_and_path(path):
|
||||
r"""Guess the long-path prefix based on the kind of *path*.
|
||||
Local paths (C:\folder\file.ext) and UNC names (\\server\folder\file.ext)
|
||||
are handled.
|
||||
|
||||
Return a tuple of the long-path prefix and the prefixed path.
|
||||
"""
|
||||
prefix, long_path = "\\\\?\\", path
|
||||
|
||||
if not path.startswith(prefix):
|
||||
if path.startswith("\\\\"):
|
||||
# Likely a UNC name
|
||||
prefix = "\\\\?\\UNC"
|
||||
long_path = prefix + path[1:]
|
||||
else:
|
||||
# Likely a local path
|
||||
long_path = prefix + path
|
||||
elif path.startswith(prefix + "UNC\\"):
|
||||
# UNC name with long-path prefix
|
||||
prefix = "\\\\?\\UNC"
|
||||
|
||||
return prefix, long_path
|
||||
|
||||
|
||||
def get_awaited_path_from_prefix(prefix, path):
|
||||
"""Guess the correct path to pass to the SHFileOperationW() call.
|
||||
The long-path prefix must be removed, so we should take care of
|
||||
different long-path prefixes.
|
||||
"""
|
||||
if prefix == "\\\\?\\UNC":
|
||||
# We need to prepend a backslash for UNC names, as it was removed
|
||||
# in prefix_and_path().
|
||||
return "\\" + path[len(prefix) :]
|
||||
return path[len(prefix) :]
|
||||
|
||||
|
||||
def get_short_path_name(long_name):
|
||||
prefix, long_path = prefix_and_path(long_name)
|
||||
buf_size = GetShortPathNameW(long_path, 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_path)
|
||||
output = create_unicode_buffer(buf_size)
|
||||
GetShortPathNameW(long_path, output, buf_size)
|
||||
return get_awaited_path_from_prefix(prefix, output.value)
|
||||
|
||||
|
||||
def send2trash(paths):
|
||||
paths = preprocess_paths(paths)
|
||||
if not paths:
|
||||
return
|
||||
# convert data type
|
||||
paths = [text_type(path, "mbcs") if not isinstance(path, text_type) else path for path in paths]
|
||||
# convert to full paths
|
||||
paths = [op.abspath(path) if not op.isabs(path) else path for path in paths]
|
||||
# get short path to handle path length issues
|
||||
paths = [get_short_path_name(path) for path in paths]
|
||||
fileop = SHFILEOPSTRUCTW()
|
||||
fileop.hwnd = 0
|
||||
fileop.wFunc = FO_DELETE
|
||||
# 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'.
|
||||
# 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.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:
|
||||
error = convert_sh_file_opt_result(result)
|
||||
raise WindowsError(None, FormatError(error), paths, error)
|
||||
66
send2trash/win/modern.py
Normal file
66
send2trash/win/modern.py
Normal file
@@ -0,0 +1,66 @@
|
||||
# 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
|
||||
import os.path as op
|
||||
from send2trash.compat import text_type
|
||||
from send2trash.util import preprocess_paths
|
||||
from platform import version
|
||||
import pythoncom
|
||||
import pywintypes
|
||||
from win32com.shell import shell, shellcon
|
||||
from send2trash.win.IFileOperationProgressSink import create_sink
|
||||
|
||||
|
||||
def send2trash(paths):
|
||||
paths = preprocess_paths(paths)
|
||||
if not paths:
|
||||
return
|
||||
# convert data type
|
||||
paths = [text_type(path, "mbcs") if not isinstance(path, text_type) else path for path in paths]
|
||||
# convert to full paths
|
||||
paths = [op.abspath(path) if not op.isabs(path) else path for path in paths]
|
||||
# remove the leading \\?\ if present
|
||||
paths = [path[4:] if path.startswith("\\\\?\\") else path for path in paths]
|
||||
# Need to initialize the com before using
|
||||
pythoncom.CoInitialize()
|
||||
# create instance of file operation object
|
||||
fileop = pythoncom.CoCreateInstance(
|
||||
shell.CLSID_FileOperation,
|
||||
None,
|
||||
pythoncom.CLSCTX_ALL,
|
||||
shell.IID_IFileOperation,
|
||||
)
|
||||
# default flags to use
|
||||
flags = shellcon.FOF_NOCONFIRMATION | shellcon.FOF_NOERRORUI | shellcon.FOF_SILENT | shellcon.FOFX_EARLYFAILURE
|
||||
# determine rest of the flags based on OS version
|
||||
# use newer recommended flags if available
|
||||
if int(version().split(".", 1)[0]) >= 8:
|
||||
flags |= 0x20000000 | 0x00080000 # FOFX_ADDUNDORECORD win 8+ # FOFX_RECYCLEONDELETE win 8+
|
||||
else:
|
||||
flags |= shellcon.FOF_ALLOWUNDO
|
||||
# set the flags
|
||||
fileop.SetOperationFlags(flags)
|
||||
# actually try to perform the operation, this section may throw a
|
||||
# pywintypes.com_error which does not seem to create as nice of an
|
||||
# error as OSError so wrapping with try to convert
|
||||
sink = create_sink()
|
||||
try:
|
||||
for path in paths:
|
||||
item = shell.SHCreateItemFromParsingName(path, None, shell.IID_IShellItem)
|
||||
fileop.DeleteItem(item, sink)
|
||||
result = fileop.PerformOperations()
|
||||
aborted = fileop.GetAnyOperationsAborted()
|
||||
# if non-zero result or aborted throw an exception
|
||||
if result or aborted:
|
||||
raise OSError(None, None, paths, result)
|
||||
except pywintypes.com_error as error:
|
||||
# convert to standard OS error, allows other code to get a
|
||||
# normal errno
|
||||
raise OSError(None, error.strerror, path, error.hresult)
|
||||
finally:
|
||||
# Need to make sure we call this once fore every init
|
||||
pythoncom.CoUninitialize()
|
||||
52
setup.cfg
Normal file
52
setup.cfg
Normal file
@@ -0,0 +1,52 @@
|
||||
[metadata]
|
||||
name = Send2Trash
|
||||
version = 1.8.2
|
||||
url = https://github.com/arsenetar/send2trash
|
||||
project_urls =
|
||||
Bug Reports = https://github.com/arsenetar/send2trash/issues
|
||||
author = Andrew Senetar
|
||||
author_email = arsenetar@voltaicideas.net
|
||||
license = BSD License
|
||||
license_files = LICENSE
|
||||
description = Send file to trash natively under Mac OS X, Windows and Linux
|
||||
long_description = file:README.rst
|
||||
long_description_content_type = text/x-rst
|
||||
classifiers =
|
||||
Development Status :: 5 - Production/Stable
|
||||
Intended Audience :: Developers
|
||||
License :: OSI Approved :: BSD License
|
||||
Operating System :: MacOS :: MacOS X
|
||||
Operating System :: Microsoft :: Windows
|
||||
Operating System :: POSIX
|
||||
Programming Language :: Python :: 2.7
|
||||
Programming Language :: Python :: 3
|
||||
Programming Language :: Python :: 3.5
|
||||
Programming Language :: Python :: 3.6
|
||||
Programming Language :: Python :: 3.7
|
||||
Programming Language :: Python :: 3.8
|
||||
Programming Language :: Python :: 3.9
|
||||
Programming Language :: Python :: 3.10
|
||||
Programming Language :: Python :: 3.11
|
||||
Topic :: Desktop Environment :: File Managers
|
||||
|
||||
[options]
|
||||
packages = find:
|
||||
tests_require = pytest
|
||||
python_requires = >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*
|
||||
|
||||
[options.packages.find]
|
||||
include=
|
||||
send2trash*
|
||||
|
||||
[options.extras_require]
|
||||
win32 =
|
||||
pywin32; sys_platform == "win32"
|
||||
objc =
|
||||
pyobjc-framework-Cocoa; sys_platform == "darwin"
|
||||
nativeLib =
|
||||
pywin32; sys_platform == "win32"
|
||||
pyobjc-framework-Cocoa; sys_platform == "darwin"
|
||||
|
||||
[options.entry_points]
|
||||
console_scripts =
|
||||
send2trash = send2trash.__main__:main
|
||||
32
setup.py
32
setup.py
@@ -1,32 +0,0 @@
|
||||
import sys
|
||||
import os.path as op
|
||||
|
||||
from setuptools import setup
|
||||
|
||||
CLASSIFIERS = [
|
||||
'Development Status :: 5 - Production/Stable',
|
||||
'Intended Audience :: Developers',
|
||||
'License :: OSI Approved :: BSD License',
|
||||
'Operating System :: MacOS :: MacOS X',
|
||||
'Operating System :: Microsoft :: Windows',
|
||||
'Operating System :: POSIX',
|
||||
'Programming Language :: Python :: 2.7',
|
||||
'Programming Language :: Python :: 3',
|
||||
'Topic :: Desktop Environment :: File Managers',
|
||||
]
|
||||
|
||||
LONG_DESCRIPTION = open('README.rst', 'rt').read() + '\n\n' + open('CHANGES.rst', 'rt').read()
|
||||
|
||||
setup(
|
||||
name='Send2Trash',
|
||||
version='1.3.0',
|
||||
author='Hardcoded Software',
|
||||
author_email='hsoft@hardcoded.net',
|
||||
packages=['send2trash'],
|
||||
scripts=[],
|
||||
url='http://github.com/hsoft/send2trash',
|
||||
license='BSD License',
|
||||
description='Send file to trash natively under Mac OS X, Windows and Linux.',
|
||||
long_description=LONG_DESCRIPTION,
|
||||
classifiers=CLASSIFIERS,
|
||||
)
|
||||
@@ -1,131 +0,0 @@
|
||||
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()
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
231
tests/test_plat_other.py
Normal file
231
tests/test_plat_other.py
Normal file
@@ -0,0 +1,231 @@
|
||||
# encoding: utf-8
|
||||
import pytest
|
||||
import codecs
|
||||
import os
|
||||
import sys
|
||||
from os import path as op
|
||||
from send2trash.compat import PY3
|
||||
from send2trash import TrashPermissionError
|
||||
|
||||
try:
|
||||
from configparser import ConfigParser
|
||||
except ImportError:
|
||||
# py2
|
||||
from ConfigParser import ConfigParser # noqa: F401
|
||||
|
||||
from tempfile import mkdtemp, NamedTemporaryFile
|
||||
import shutil
|
||||
import stat
|
||||
import uuid
|
||||
|
||||
if sys.platform != "win32":
|
||||
import send2trash.plat_other
|
||||
from send2trash.plat_other import send2trash as s2t
|
||||
|
||||
INFO_SUFFIX = send2trash.plat_other.INFO_SUFFIX.decode()
|
||||
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 + INFO_SUFFIX))
|
||||
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 + INFO_SUFFIX)) 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):
|
||||
with open(path, "a"):
|
||||
os.utime(path, None)
|
||||
|
||||
|
||||
def _filesys_enc():
|
||||
enc = sys.getfilesystemencoding()
|
||||
# Get canonical name of codec
|
||||
return codecs.lookup(enc).name
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gen_unicode_file():
|
||||
name = u"send2trash_tést1"
|
||||
file = op.join(op.expanduser(b"~"), name.encode("utf-8"))
|
||||
touch(file)
|
||||
assert op.exists(file) is True
|
||||
yield file
|
||||
# Cleanup trash files on supported platforms
|
||||
if sys.platform != "win32" and op.exists(op.join(HOMETRASH, "files", name)):
|
||||
os.remove(op.join(HOMETRASH, "files", name))
|
||||
os.remove(op.join(HOMETRASH, "info", name + INFO_SUFFIX))
|
||||
if op.exists(file):
|
||||
os.remove(file)
|
||||
|
||||
|
||||
@pytest.mark.skipif(_filesys_enc() == "ascii", reason="Requires Unicode filesystem")
|
||||
def test_trash_bytes(gen_unicode_file):
|
||||
s2t(gen_unicode_file)
|
||||
assert not op.exists(gen_unicode_file)
|
||||
|
||||
|
||||
@pytest.mark.skipif(_filesys_enc() == "ascii", reason="Requires Unicode filesystem")
|
||||
def test_trash_unicode(gen_unicode_file):
|
||||
s2t(gen_unicode_file.decode(sys.getfilesystemencoding()))
|
||||
assert not op.exists(gen_unicode_file)
|
||||
|
||||
|
||||
class ExtVol:
|
||||
def __init__(self, path):
|
||||
self.trash_topdir = path
|
||||
if PY3:
|
||||
self.trash_topdir_b = os.fsencode(self.trash_topdir)
|
||||
else:
|
||||
self.trash_topdir_b = self.trash_topdir
|
||||
|
||||
def s_getdev(path):
|
||||
from send2trash.plat_other import is_parent
|
||||
|
||||
st = os.lstat(path)
|
||||
if is_parent(self.trash_topdir, path):
|
||||
return "dev"
|
||||
return st.st_dev
|
||||
|
||||
def s_ismount(path):
|
||||
if op.realpath(path) in (
|
||||
op.realpath(self.trash_topdir),
|
||||
op.realpath(self.trash_topdir_b),
|
||||
):
|
||||
return True
|
||||
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.get_dev = s_getdev
|
||||
|
||||
def cleanup(self):
|
||||
send2trash.plat_other.get_dev = self.old_getdev
|
||||
send2trash.plat_other.os.path.ismount = self.old_ismount
|
||||
shutil.rmtree(self.trash_topdir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gen_ext_vol():
|
||||
trash_topdir = mkdtemp(prefix="s2t")
|
||||
volume = ExtVol(trash_topdir)
|
||||
file_name = "test.txt"
|
||||
file_path = op.join(volume.trash_topdir, file_name)
|
||||
touch(file_path)
|
||||
assert op.exists(file_path) is True
|
||||
yield volume, file_name, file_path
|
||||
volume.cleanup()
|
||||
|
||||
|
||||
def test_trash_topdir(gen_ext_vol):
|
||||
trash_dir = op.join(gen_ext_vol[0].trash_topdir, ".Trash")
|
||||
os.mkdir(trash_dir, 0o777 | stat.S_ISVTX)
|
||||
|
||||
s2t(gen_ext_vol[2])
|
||||
assert op.exists(gen_ext_vol[2]) is False
|
||||
assert op.exists(op.join(trash_dir, str(os.getuid()), "files", gen_ext_vol[1])) is True
|
||||
assert (
|
||||
op.exists(
|
||||
op.join(
|
||||
trash_dir,
|
||||
str(os.getuid()),
|
||||
"info",
|
||||
gen_ext_vol[1] + INFO_SUFFIX,
|
||||
)
|
||||
)
|
||||
is True
|
||||
)
|
||||
# info relative path (if another test is added, with the same fileName/Path,
|
||||
# then it gets renamed etc.)
|
||||
cfg = ConfigParser()
|
||||
cfg.read(op.join(trash_dir, str(os.getuid()), "info", gen_ext_vol[1] + INFO_SUFFIX))
|
||||
assert (gen_ext_vol[1] == cfg.get("Trash Info", "Path", raw=True)) is True
|
||||
|
||||
|
||||
def test_trash_topdir_fallback(gen_ext_vol):
|
||||
s2t(gen_ext_vol[2])
|
||||
assert op.exists(gen_ext_vol[2]) is False
|
||||
assert (
|
||||
op.exists(
|
||||
op.join(
|
||||
gen_ext_vol[0].trash_topdir,
|
||||
".Trash-" + str(os.getuid()),
|
||||
"files",
|
||||
gen_ext_vol[1],
|
||||
)
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_trash_topdir_failure(gen_ext_vol):
|
||||
os.chmod(gen_ext_vol[0].trash_topdir, 0o500) # not writable to induce the exception
|
||||
pytest.raises(TrashPermissionError, s2t, [gen_ext_vol[2]])
|
||||
os.chmod(gen_ext_vol[0].trash_topdir, 0o700) # writable to allow deletion
|
||||
|
||||
|
||||
def test_trash_symlink(gen_ext_vol):
|
||||
# Generating a random uuid named path for symlink
|
||||
sl_dir = op.join(op.expanduser("~"), "s2t_" + str(uuid.uuid4()))
|
||||
os.mkdir(op.join(gen_ext_vol[0].trash_topdir, "subdir"), 0o700)
|
||||
file_path = op.join(gen_ext_vol[0].trash_topdir, "subdir", gen_ext_vol[1])
|
||||
touch(file_path)
|
||||
os.symlink(op.join(gen_ext_vol[0].trash_topdir, "subdir"), sl_dir)
|
||||
s2t(op.join(sl_dir, gen_ext_vol[1]))
|
||||
assert op.exists(file_path) is False
|
||||
assert (
|
||||
op.exists(
|
||||
op.join(
|
||||
gen_ext_vol[0].trash_topdir,
|
||||
".Trash-" + str(os.getuid()),
|
||||
"files",
|
||||
gen_ext_vol[1],
|
||||
)
|
||||
)
|
||||
is True
|
||||
)
|
||||
os.remove(sl_dir)
|
||||
220
tests/test_plat_win.py
Normal file
220
tests/test_plat_win.py
Normal file
@@ -0,0 +1,220 @@
|
||||
# encoding: utf-8
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import pytest
|
||||
from os import path as op
|
||||
|
||||
from send2trash import send2trash as s2t
|
||||
|
||||
# import the two versions as well as the "automatic" version
|
||||
if sys.platform == "win32":
|
||||
from send2trash.win.modern import send2trash as s2t_modern
|
||||
from send2trash.win.legacy import send2trash as s2t_legacy
|
||||
else:
|
||||
pytest.skip("Skipping windows-only tests", allow_module_level=True)
|
||||
|
||||
|
||||
def _create_tree(path):
|
||||
dirname = op.dirname(path)
|
||||
if not op.isdir(dirname):
|
||||
os.makedirs(dirname)
|
||||
with open(path, "w") as writer:
|
||||
writer.write("send2trash test")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def testdir(tmp_path):
|
||||
dirname = "\\\\?\\" + str(tmp_path)
|
||||
assert op.exists(dirname) is True
|
||||
yield dirname
|
||||
shutil.rmtree(dirname, ignore_errors=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def testfile(testdir):
|
||||
file = op.join(testdir, "testfile.txt")
|
||||
_create_tree(file)
|
||||
assert op.exists(file) is True
|
||||
yield file
|
||||
# Note dir will cleanup the file
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def testfiles(testdir):
|
||||
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(dir, fcn):
|
||||
fcn(dir)
|
||||
assert op.exists(dir) is False
|
||||
|
||||
|
||||
def _trash_file(file, fcn):
|
||||
fcn(file)
|
||||
assert op.exists(file) is False
|
||||
|
||||
|
||||
def _trash_multifile(files, fcn):
|
||||
fcn(files)
|
||||
assert any([op.exists(file) for file in files]) is False
|
||||
|
||||
|
||||
def _file_not_found(dir, fcn):
|
||||
file = op.join(dir, "otherfile.txt")
|
||||
pytest.raises(OSError, fcn, file)
|
||||
|
||||
|
||||
def _multi_byte_unicode(dir, fcn):
|
||||
single_file = op.join(dir, "😇.txt")
|
||||
_create_tree(single_file)
|
||||
assert op.exists(single_file) is True
|
||||
fcn(single_file)
|
||||
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)
|
||||
try:
|
||||
shutil.rmtree(dirname, ignore_errors=True)
|
||||
except TypeError:
|
||||
pass
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
def test_trash_nothing_legacy():
|
||||
try:
|
||||
s2t_legacy([])
|
||||
except Exception as ex:
|
||||
assert False, "Exception thrown when trashing nothing: {}".format(ex)
|
||||
|
||||
|
||||
def test_trash_nothing_modern():
|
||||
try:
|
||||
s2t_modern([])
|
||||
except Exception as ex:
|
||||
assert False, "Exception thrown when trashing nothing: {}".format(ex)
|
||||
41
tests/test_script_main.py
Normal file
41
tests/test_script_main.py
Normal file
@@ -0,0 +1,41 @@
|
||||
# encoding: utf-8
|
||||
import os
|
||||
import sys
|
||||
import pytest
|
||||
from tempfile import NamedTemporaryFile
|
||||
from os import path as op
|
||||
|
||||
from send2trash.__main__ import main as trash_main
|
||||
|
||||
# Only import HOMETRASH on supported platforms
|
||||
if sys.platform != "win32":
|
||||
from send2trash.plat_other import HOMETRASH
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def file():
|
||||
file = NamedTemporaryFile(dir=op.expanduser("~"), prefix="send2trash_test", delete=False)
|
||||
file.close()
|
||||
# Verify file was actually created
|
||||
assert op.exists(file.name) is True
|
||||
yield file.name
|
||||
# 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)
|
||||
|
||||
|
||||
def test_trash(file):
|
||||
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
|
||||
25
tox.ini
Normal file
25
tox.ini
Normal file
@@ -0,0 +1,25 @@
|
||||
[tox]
|
||||
envlist = py{27,34,35,36,37,38,39,310}
|
||||
skip_missing_interpreters = True
|
||||
isolated_build = True
|
||||
|
||||
[testenv]
|
||||
deps =
|
||||
flake8
|
||||
pytest
|
||||
pywin32; sys_platform == 'win32'
|
||||
pyobjc-framework-Cocoa; sys_platform == 'darwin'
|
||||
commands =
|
||||
flake8
|
||||
pytest
|
||||
|
||||
[testenv:py27]
|
||||
deps =
|
||||
configparser
|
||||
{[testenv]deps}
|
||||
|
||||
[flake8]
|
||||
exclude = .tox,env,build
|
||||
max-line-length = 120
|
||||
select = C,E,F,W,B,B950
|
||||
extend-ignore = E203, E501
|
||||
Reference in New Issue
Block a user