From 9465587899c59c92e10bed963caf79200fcf7548 Mon Sep 17 00:00:00 2001 From: Andrew Senetar Date: Wed, 31 Dec 2025 01:29:34 +0000 Subject: [PATCH 1/9] chore: Update CI workflow versions add python 3.14 - Add Python 3.14 - Update GitHub Actions to latest versions - Enable macOS in CI --- .github/workflows/codeql-analysis.yml | 44 +++------------------------ .github/workflows/default.yml | 20 ++++++------ setup.cfg | 1 + tox.ini | 2 +- 4 files changed, 17 insertions(+), 50 deletions(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 8cbcc5f..abbdbc0 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,14 +1,3 @@ -# 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: @@ -33,39 +22,14 @@ jobs: 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. + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 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 - + uses: github/codeql-action/autobuild@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@5d4e8d1aca955e8d8589aabd499c5cae939e33c7 # v4.31.9 diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml index 6173a8f..11ac649 100644 --- a/.github/workflows/default.yml +++ b/.github/workflows/default.yml @@ -8,9 +8,9 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Python 3.x - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: 3.x - name: Install dependencies @@ -26,6 +26,8 @@ jobs: strategy: matrix: include: + - os: ubuntu-latest + python-version: 3.14 - os: ubuntu-latest python-version: 3.13 - os: ubuntu-latest @@ -38,19 +40,19 @@ jobs: python-version: 3.9 - os: ubuntu-latest python-version: 3.8 - # - os: macos-latest - # python-version: 3.13 - # - os: macos-latest - # python-version: 3.8 + - os: macos-latest + python-version: 3.14 + - os: macos-latest + python-version: 3.8 - os: windows-latest - python-version: 3.13 + python-version: 3.14 - os: windows-latest python-version: 3.8 steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/setup.cfg b/setup.cfg index 900e96d..6763e6b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -25,6 +25,7 @@ classifiers = Programming Language :: Python :: 3.11 Programming Language :: Python :: 3.12 Programming Language :: Python :: 3.13 + Programming Language :: Python :: 3.14 Topic :: Desktop Environment :: File Managers [options] diff --git a/tox.ini b/tox.ini index 4b7b0d9..1bc1b99 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py{38,39,310,311,312,313} +envlist = py{38,39,310,311,312,313,314} skip_missing_interpreters = True isolated_build = True From b23cba1ae29f80fb6affad91984d03e83e4c3637 Mon Sep 17 00:00:00 2001 From: Andrew Senetar Date: Wed, 31 Dec 2025 01:32:01 +0000 Subject: [PATCH 2/9] chore: Fix lint error in test_plat_other.py --- tests/test_plat_other.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_plat_other.py b/tests/test_plat_other.py index 0dac205..3bbcb6c 100644 --- a/tests/test_plat_other.py +++ b/tests/test_plat_other.py @@ -163,7 +163,7 @@ def test_trash_topdir(gen_ext_vol): s2t(gen_ext_vol[2]) assert op.exists(gen_ext_vol[2]) is False - + if sys.platform == "darwin": # On macOS, we can only verify the file was removed from original location pass @@ -181,7 +181,7 @@ def test_trash_topdir(gen_ext_vol): ) is True ) - + 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 From 0a4473d9549a04bccc4533b58271586082a72581 Mon Sep 17 00:00:00 2001 From: Andrew Senetar Date: Wed, 31 Dec 2025 01:34:12 +0000 Subject: [PATCH 3/9] ci: Test python 3.9 on macOS instead of 3.8 as min version PyObjC needs at least 3.9 now. --- .github/workflows/default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/default.yml b/.github/workflows/default.yml index 11ac649..c818a8d 100644 --- a/.github/workflows/default.yml +++ b/.github/workflows/default.yml @@ -43,7 +43,7 @@ jobs: - os: macos-latest python-version: 3.14 - os: macos-latest - python-version: 3.8 + python-version: 3.9 - os: windows-latest python-version: 3.14 - os: windows-latest From 8d96aa29dfb7598d8f2f500d165afdd0c9e5224e Mon Sep 17 00:00:00 2001 From: Andrew Senetar Date: Wed, 31 Dec 2025 02:30:38 +0000 Subject: [PATCH 4/9] tests: Cleanup some pylint errors and share common fixture - Cleanup some of the pylint erros in the tests - Reorganize some of the tests functions and fixtures - Move the one common fixture to conftest.py for sharing --- tests/conftest.py | 27 ++++ tests/test_plat_other.py | 82 ++++------- tests/test_plat_win.py | 279 +++++++++++++++++++------------------- tests/test_script_main.py | 39 +----- 4 files changed, 201 insertions(+), 226 deletions(-) create mode 100644 tests/conftest.py diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0753384 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,27 @@ +# encoding: utf-8 +import sys +import os +from tempfile import NamedTemporaryFile +import pytest + +# Only import HOMETRASH on supported platforms +if sys.platform != "win32": + from send2trash.plat_other import HOMETRASH + + +@pytest.fixture(name="test_file") +def fixture_test_file(): + file = NamedTemporaryFile(dir=os.path.expanduser("~"), prefix="send2trash_test", delete=False) + file.close() + # Verify file was actually created + assert os.path.exists(file.name) is True + yield file.name + # Cleanup trash files on supported platforms + if sys.platform != "win32": + name = os.path.basename(file.name) + # Remove trash files if they exist + if os.path.exists(os.path.join(HOMETRASH, "files", name)): + os.remove(os.path.join(HOMETRASH, "files", name)) + os.remove(os.path.join(HOMETRASH, "info", name + ".trashinfo")) + if os.path.exists(file.name): + os.remove(file.name) diff --git a/tests/test_plat_other.py b/tests/test_plat_other.py index 3bbcb6c..dc340ee 100644 --- a/tests/test_plat_other.py +++ b/tests/test_plat_other.py @@ -1,82 +1,62 @@ # encoding: utf-8 -import pytest import codecs import os import sys from os import path as op -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 +from configparser import ConfigParser +import pytest +from send2trash import TrashPermissionError -if sys.platform != "win32": +if sys.platform == "win32": + pytest.skip("Skipping non-windows tests", allow_module_level=True) +else: import send2trash.plat_other from send2trash.plat_other import send2trash as s2t + from send2trash.plat_other import is_parent - INFO_SUFFIX = send2trash.plat_other.INFO_SUFFIX.decode() - HOMETRASH = send2trash.plat_other.HOMETRASH -else: - pytest.skip("Skipping non-windows tests", allow_module_level=True) +INFO_SUFFIX = send2trash.plat_other.INFO_SUFFIX.decode() +HOMETRASH = send2trash.plat_other.HOMETRASH -@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(): +@pytest.fixture(name="test_files") +def fixture_test_files(): files = list( map( lambda index: NamedTemporaryFile( dir=op.expanduser("~"), - prefix="send2trash_test{}".format(index), + prefix=f"send2trash_test{index}", delete=False, ), range(10), ) ) - [file.close() for file in files] - assert all([op.exists(file.name) for file in files]) is True + for file in files: + file.close() + 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] + for filename in filenames: + os.remove(op.join(HOMETRASH, "files", filename)) + os.remove(op.join(HOMETRASH, "info", filename + INFO_SUFFIX)) -def test_trash(testfile): - s2t(testfile.name) - assert op.exists(testfile.name) is False +def test_trash(test_file): + s2t(test_file) + assert op.exists(test_file) 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 test_multitrash(test_files): + file_names = [file.name for file in test_files] + s2t(file_names) + assert any(op.exists(filename) for filename in file_names) is False def touch(path): - with open(path, "a"): + with open(path, "a", encoding="utf-8"): os.utime(path, None) @@ -86,8 +66,8 @@ def _filesys_enc(): return codecs.lookup(enc).name -@pytest.fixture -def gen_unicode_file(): +@pytest.fixture(name="gen_unicode_file") +def fixture_gen_unicode_file(): name = "send2trash_tΓ©st1" file = op.join(op.expanduser(b"~"), name.encode("utf-8")) touch(file) @@ -119,8 +99,6 @@ class ExtVol: self.trash_topdir_b = os.fsencode(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" @@ -145,8 +123,8 @@ class ExtVol: shutil.rmtree(self.trash_topdir) -@pytest.fixture -def gen_ext_vol(): +@pytest.fixture(name="gen_ext_vol") +def fixture_gen_ext_vol(): trash_topdir = mkdtemp(prefix="s2t") volume = ExtVol(trash_topdir) file_name = "test.txt" diff --git a/tests/test_plat_win.py b/tests/test_plat_win.py index bf77437..b595fd2 100644 --- a/tests/test_plat_win.py +++ b/tests/test_plat_win.py @@ -2,56 +2,98 @@ import os import shutil import sys -import pytest from os import path as op - +import pytest from send2trash import send2trash as s2t -# import the two versions as well as the "automatic" version -if sys.platform == "win32": +s2t_modern = None +s2t_legacy = None + +if sys.platform != "win32": + pytest.skip("Skipping windows-only tests", allow_module_level=True) +else: + # import the two versions as well as the "automatic" version 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) + +if s2t_modern is None: + pytest.fail("Modern send2trash not available") + +if s2t_legacy is None: + pytest.fail("Legacy send2trash not available") def _create_tree(path): - dirname = op.dirname(path) - if not op.isdir(dirname): - os.makedirs(dirname) - with open(path, "w") as writer: + dir_name = op.dirname(path) + if not op.isdir(dir_name): + os.makedirs(dir_name) + with open(path, "w", encoding="utf-8") 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(name="test_dir") +def fixture_test_dir(tmp_path): + dir_name = "\\\\?\\" + str(tmp_path) + assert op.exists(dir_name) is True + yield dir_name + shutil.rmtree(dir_name, ignore_errors=True) -@pytest.fixture -def testfile(testdir): - file = op.join(testdir, "testfile.txt") +@pytest.fixture(name="test_file") +def fixture_test_file(test_dir): + file = op.join(test_dir, "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 +@pytest.fixture(name="test_files") +def fixture_test_files(test_dir): + files = [op.join(test_dir, f"testfile{index}.txt") for index in range(10)] + for file in files: + _create_tree(file) + 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 +# Long path tests +@pytest.fixture(name="long_dir") +def fixture_long_dir(tmp_path): + dir_name = "\\\\?\\" + str(tmp_path) + name = "A" * 100 + yield op.join(dir_name, name, name, name) + try: + shutil.rmtree(dir_name, ignore_errors=True) + except TypeError: + pass + + +@pytest.fixture(name="long_file") +def fixture_long_file(long_dir): + name = "A" * 100 + path = op.join(long_dir, name + "{}.txt") + file = path.format("") + _create_tree(file) + assert op.exists(file) is True + yield file + + +@pytest.fixture(name="long_files") +def fixture_long_files(long_dir): + name = "A" * 100 + path = op.join(long_dir, name + "{}.txt") + files = [path.format(index) for index in range(10)] + for file in files: + _create_tree(file) + assert all(op.exists(file) for file in files) is True + yield files + + +def _trash_folder(folder, fcn): + fcn(folder) + assert op.exists(folder) is False def _trash_file(file, fcn): @@ -61,160 +103,113 @@ def _trash_file(file, fcn): def _trash_multifile(files, fcn): fcn(files) - assert any([op.exists(file) for file in files]) is False + assert any(op.exists(file) for file in files) is False -def _file_not_found(dir, fcn): - file = op.join(dir, "otherfile.txt") +def _file_not_found(folder, fcn): + file = op.join(folder, "otherfile.txt") pytest.raises(OSError, fcn, file) -def _multi_byte_unicode(dir, fcn): - single_file = op.join(dir, "πŸ˜‡.txt") +def _multi_byte_unicode(folder, fcn): + single_file = op.join(folder, "πŸ˜‡.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 + files = [op.join(folder, f"πŸ˜‡{index}.txt") for index in range(10)] + for file in files: + _create_tree(file) + assert all(op.exists(file) for file in files) is True fcn(files) - assert any([op.exists(file) for file in files]) is False + assert any(op.exists(file) for file in files) is False -def test_trash_folder(testdir): - _trash_folder(testdir, s2t) +def test_trash_folder(test_dir): + _trash_folder(test_dir, s2t) -def test_trash_file(testfile): - _trash_file(testfile, s2t) +def test_trash_file(test_file): + _trash_file(test_file, s2t) -def test_trash_multifile(testfiles): - _trash_multifile(testfiles, s2t) +def test_trash_multifile(test_files): + _trash_multifile(test_files, s2t) -def test_file_not_found(testdir): - _file_not_found(testdir, s2t) +def test_file_not_found(test_dir): + _file_not_found(test_dir, s2t) -def test_trash_folder_modern(testdir): - _trash_folder(testdir, s2t_modern) +def test_trash_folder_modern(test_dir): + _trash_folder(test_dir, s2t_modern) -def test_trash_file_modern(testfile): - _trash_file(testfile, s2t_modern) +def test_trash_file_modern(test_file): + _trash_file(test_file, s2t_modern) -def test_trash_multifile_modern(testfiles): - _trash_multifile(testfiles, s2t_modern) +def test_trash_multifile_modern(test_files): + _trash_multifile(test_files, s2t_modern) -def test_file_not_found_modern(testdir): - _file_not_found(testdir, s2t_modern) +def test_file_not_found_modern(test_dir): + _file_not_found(test_dir, 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 +def test_multi_byte_unicode_modern(test_dir): + _multi_byte_unicode(test_dir, s2t_modern) # 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_file_modern(long_file): + _trash_file(long_file, 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_long_multifile_modern(long_files): + _trash_multifile(long_files, s2t_modern) def test_trash_nothing_modern(): try: s2t_modern([]) except Exception as ex: - assert False, "Exception thrown when trashing nothing: {}".format(ex) + assert False, f"Exception thrown when trashing nothing: {ex}" + + +def test_trash_folder_legacy(test_dir): + _trash_folder(test_dir, s2t_legacy) + + +def test_trash_file_legacy(test_file): + _trash_file(test_file, s2t_legacy) + + +def test_trash_multifile_legacy(test_files): + _trash_multifile(test_files, s2t_legacy) + + +def test_file_not_found_legacy(test_dir): + _file_not_found(test_dir, s2t_legacy) + + +def test_multi_byte_unicode_legacy(test_dir): + _multi_byte_unicode(test_dir, s2t_legacy) + + +def test_trash_long_file_legacy(long_file): + _trash_file(long_file, s2t_legacy) + + +def test_trash_long_multifile_legacy(long_files): + _trash_multifile(long_files, s2t_legacy) + + +def test_trash_nothing_legacy(): + try: + s2t_legacy([]) + except Exception as ex: + assert False, f"Exception thrown when trashing nothing: {ex}" diff --git a/tests/test_script_main.py b/tests/test_script_main.py index cc5c669..c21fa92 100644 --- a/tests/test_script_main.py +++ b/tests/test_script_main.py @@ -1,41 +1,16 @@ # encoding: utf-8 -import os -import sys -import pytest -from tempfile import NamedTemporaryFile from os import path as op +import pytest from send2trash.__main__ import main as trash_main -# Only import HOMETRASH on supported platforms -if sys.platform != "win32": - from send2trash.plat_other import HOMETRASH + +def test_trash(test_file): + trash_main(["-v", test_file]) + assert op.exists(test_file) is False -@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): +def test_no_args(test_file): pytest.raises(SystemExit, trash_main, []) pytest.raises(SystemExit, trash_main, ["-v"]) - assert op.exists(file) is True + assert op.exists(test_file) is True From 06a9d37805fc6a4631616a9ea4994b929efc73a0 Mon Sep 17 00:00:00 2001 From: Andrew Senetar Date: Wed, 31 Dec 2025 02:54:09 +0000 Subject: [PATCH 5/9] chore: More pylint cleanups, and python2 removals --- send2trash/plat_gio.py | 4 +-- send2trash/plat_other.py | 77 +++++++++++++++------------------------- send2trash/util.py | 4 +-- send2trash/win/legacy.py | 5 ++- send2trash/win/modern.py | 4 +-- 5 files changed, 36 insertions(+), 58 deletions(-) diff --git a/send2trash/plat_gio.py b/send2trash/plat_gio.py index 258e4ef..178b033 100644 --- a/send2trash/plat_gio.py +++ b/send2trash/plat_gio.py @@ -19,5 +19,5 @@ def send2trash(paths): 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) + raise TrashPermissionError("") from e + raise OSError(e.message) from e diff --git a/send2trash/plat_other.py b/send2trash/plat_other.py index 617ff6c..4aee92b 100644 --- a/send2trash/plat_other.py +++ b/send2trash/plat_other.py @@ -17,36 +17,15 @@ from __future__ import unicode_literals import errno -import sys -import os import shutil +import os 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 - +from urllib.parse import quote 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" @@ -54,7 +33,7 @@ INFO_SUFFIX = b".trashinfo" # Default of ~/.local/share [3] XDG_DATA_HOME = op.expanduser(os.environb.get(b"XDG_DATA_HOME", b"~/.local/share")) HOMETRASH_B = op.join(XDG_DATA_HOME, b"Trash") -HOMETRASH = fsdecode(HOMETRASH_B) +HOMETRASH = os.fsdecode(HOMETRASH_B) uid = os.getuid() TOPDIR_TRASH = b".Trash" @@ -64,10 +43,10 @@ TOPDIR_FALLBACK = b".Trash-" + str(uid).encode("ascii") def is_parent(parent, path): path = op.realpath(path) # In case it's a symlink if isinstance(path, str): - path = fsencode(path) + path = os.fsencode(path) parent = op.realpath(parent) if isinstance(parent, str): - parent = fsencode(parent) + parent = os.fsencode(parent) return path.startswith(parent) @@ -89,34 +68,34 @@ def info_for(src, topdir): return info -def check_create(dir): +def check_create(folder): # use 0700 for paths [3] - if not op.exists(dir): - os.makedirs(dir, 0o700) + if not op.exists(folder): + os.makedirs(folder, 0o700) 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) - base_name, ext = op.splitext(filename) + file_name = op.basename(src) + files_path = op.join(dst, FILES_DIR) + info_path = op.join(dst, INFO_DIR) + base_name, ext = op.splitext(file_name) counter = 0 - destname = filename - while op.exists(op.join(filespath, destname)) or op.exists(op.join(infopath, destname + INFO_SUFFIX)): + dest_name = file_name + while op.exists(op.join(files_path, dest_name)) or op.exists(op.join(info_path, dest_name + INFO_SUFFIX)): counter += 1 - destname = base_name + b" " + str(counter).encode("ascii") + ext + dest_name = base_name + b" " + str(counter).encode("ascii") + ext - check_create(filespath) - check_create(infopath) + check_create(files_path) + check_create(info_path) - with open(op.join(infopath, destname + INFO_SUFFIX), "w") as f: + with open(op.join(info_path, dest_name + INFO_SUFFIX), "w") as f: f.write(info_for(src, topdir)) - destpath = op.join(filespath, destname) + dest_path = op.join(files_path, dest_name) if cross_dev: - shutil.move(fsdecode(src), fsdecode(destpath)) + shutil.move(os.fsdecode(src), os.fsdecode(dest_path)) else: - os.rename(src, destpath) + os.rename(src, dest_path) def find_mount_point(path): @@ -138,7 +117,7 @@ def find_ext_volume_global_trash(volume_root): 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): + 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).encode("ascii")) @@ -157,7 +136,7 @@ def find_ext_volume_fallback_trash(volume_root): check_create(trash_dir) except OSError as e: if e.errno == errno.EACCES: - raise TrashPermissionError(e.filename) + raise TrashPermissionError(e.filename) from e raise return trash_dir @@ -178,18 +157,18 @@ def send2trash(paths): paths = preprocess_paths(paths) for path in paths: if isinstance(path, str): - path_b = fsencode(path) + path_b = os.fsencode(path) elif isinstance(path, bytes): path_b = path else: - raise TypeError("str, bytes or PathLike expected, not %r" % type(path)) + raise TypeError(f"str, bytes or PathLike expected, not {type(path)}") if not op.exists(path_b): - raise OSError(errno.ENOENT, "File not found: %s" % path) + raise OSError(errno.ENOENT, f"File not found: {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) + raise OSError(errno.EACCES, f"Permission denied: {path}") path_dev = get_dev(path_b) # If XDG_DATA_HOME or HOMETRASH do not yet exist we need to stat the @@ -205,7 +184,7 @@ def send2trash(paths): 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) + raise OSError(f"Couldn't find mount point for {path}") dest_trash = find_ext_volume_trash(topdir) try: trash_move(path_b, dest_trash, topdir) diff --git a/send2trash/util.py b/send2trash/util.py index ea51b12..91f4591 100644 --- a/send2trash/util.py +++ b/send2trash/util.py @@ -5,6 +5,7 @@ # which should be included with this package. The terms are also available at # http://www.hardcoded.net/licenses/bsd_license +import os import collections.abc @@ -14,5 +15,4 @@ def preprocess_paths(paths): elif 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 + return [os.fspath(path) for path in paths] diff --git a/send2trash/win/legacy.py b/send2trash/win/legacy.py index f2d0767..a375415 100644 --- a/send2trash/win/legacy.py +++ b/send2trash/win/legacy.py @@ -6,9 +6,6 @@ from __future__ import unicode_literals import os.path as op - -from send2trash.util import preprocess_paths - from ctypes import ( windll, Structure, @@ -21,6 +18,8 @@ from ctypes import ( ) from ctypes.wintypes import HWND, UINT, LPCWSTR, BOOL +from send2trash.util import preprocess_paths + kernel32 = windll.kernel32 GetShortPathNameW = kernel32.GetShortPathNameW diff --git a/send2trash/win/modern.py b/send2trash/win/modern.py index 7cb60fb..09479dd 100644 --- a/send2trash/win/modern.py +++ b/send2trash/win/modern.py @@ -6,11 +6,11 @@ from __future__ import unicode_literals import os.path as op -from send2trash.util import preprocess_paths from platform import version import pythoncom import pywintypes from win32com.shell import shell, shellcon +from send2trash.util import preprocess_paths from send2trash.win.IFileOperationProgressSink import create_sink @@ -59,7 +59,7 @@ def send2trash(paths): 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) + raise OSError(None, error.strerror, path, error.hresult) from error finally: # Need to make sure we call this once fore every init pythoncom.CoUninitialize() From 2b20d606e267e582c0a3188d7534c7207badcc70 Mon Sep 17 00:00:00 2001 From: Andrew Senetar Date: Wed, 31 Dec 2025 03:01:13 +0000 Subject: [PATCH 6/9] doc: Update README to reflect python version support --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index e329ed7..7671414 100644 --- a/README.rst +++ b/README.rst @@ -11,7 +11,7 @@ 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). +Send2Trash supports Python 3 (versions before 2.x support Python 2). Status: Additional Help Welcome ------------------------------- @@ -58,7 +58,7 @@ Usage 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 +``PermissionError``. 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. From a6539d146bd274867779a519ba071cfff286217a Mon Sep 17 00:00:00 2001 From: Andrew Senetar Date: Wed, 31 Dec 2025 03:32:50 +0000 Subject: [PATCH 7/9] fix: Update gio implementation to use GLib.Error instead of deprecated GObject.GError --- send2trash/plat_gio.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/send2trash/plat_gio.py b/send2trash/plat_gio.py index 178b033..9c249be 100644 --- a/send2trash/plat_gio.py +++ b/send2trash/plat_gio.py @@ -4,7 +4,7 @@ # 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 gi.repository import Gio, GLib from send2trash.exceptions import TrashPermissionError from send2trash.util import preprocess_paths @@ -15,7 +15,7 @@ def send2trash(paths): try: f = Gio.File.new_for_path(path) f.trash(cancellable=None) - except GObject.GError as e: + except GLib.Error 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. From 2d07f76209e9418e99cf3dc34ac9b48645de1c14 Mon Sep 17 00:00:00 2001 From: Andrew Senetar Date: Wed, 31 Dec 2025 03:33:22 +0000 Subject: [PATCH 8/9] doc: Update CHANGES.rst for 2.0.0 --- CHANGES.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGES.rst b/CHANGES.rst index 1b667fc..8b70598 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,5 +1,11 @@ Changes ======= +Version 2.0.0 -- 2025/12/30 +--------------------------- +* Drop suport for Python 2 +* Fix `test_trash_topdir` failing on macOS by @denini08 in https://github.com/arsenetar/send2trash/pull/100 +* Update source installation instructions by @gunSlaveUnit in https://github.com/arsenetar/send2trash/pull/102 +* Update gio implemenation, should fix #5 by no longer using deprecated GObject.GError Version 1.8.3 -- 2024/04/06 --------------------------- From 4e8d6c7e11cd78c19cfcedb48e9ca62cc615ea39 Mon Sep 17 00:00:00 2001 From: Andrew Senetar Date: Wed, 31 Dec 2025 04:01:56 +0000 Subject: [PATCH 9/9] chore: Set version --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 6763e6b..91f98e9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = Send2Trash -version = 2.0.0-dev +version = 2.0.0 url = https://github.com/arsenetar/send2trash project_urls = Bug Reports = https://github.com/arsenetar/send2trash/issues