diff --git a/.github/workflows/ci_pytest.yml b/.github/workflows/ci_pytest.yml index 7352d66..378a4d4 100644 --- a/.github/workflows/ci_pytest.yml +++ b/.github/workflows/ci_pytest.yml @@ -44,6 +44,5 @@ jobs: - name: verbose pytest run: | - cd tests - python -m pytest unit_tests.py - python -m pytest unit_tests.py -v + pytest + pytest -v diff --git a/filetags/__init__.py b/filetags/__init__.py index 1e414c7..614d64e 100755 --- a/filetags/__init__.py +++ b/filetags/__init__.py @@ -38,14 +38,16 @@ def safe_import(library): "\".\nPlease install it, e.g., with \"sudo pip install " + library + "\".") sys.exit(2) -import re -import sys +import argparse # for handling command line arguments +import errno # for throwing FileNotFoundError +import logging import os import platform -import argparse # for handling command line arguments +import re +import stat +import sys +import tempfile import time -import logging -import errno # for throwing FileNotFoundError try: import tkinter as tk ## for --gui from tkinter import ttk, font ## for --gui @@ -375,28 +377,28 @@ class TagDialog: ## 2025-09-02 and therefore might contain errors and mistakes. ## This needs to be re-checked by somebody with Tkinter ## knowledge. - + # if widget is None: # widget = tk._default_root if widget is None: raise ValueError("No widget specified and no default root exists.") - + # Get widget's foreground and background bg = widget.cget("bg") fg = widget.cget("fg") if "fg" in widget.keys() else "black" - + # Convert to RGB r1, g1, b1 = widget.winfo_rgb(fg) r2, g2, b2 = widget.winfo_rgb(bg) - + # Blend and reduce to 8-bit r = int(r1 * ratio + r2 * (1 - ratio)) >> 8 g = int(g1 * ratio + g2 * (1 - ratio)) >> 8 b = int(b1 * ratio + b2 * (1 - ratio)) >> 8 - + return f"#{r:02x}{g:02x}{b:02x}" - + def __init__(self, root, vocabulary, upto9_tags_for_shortcuts, tags_for_visual, number_of_files, hint_str, tag_list): ## Warning: this function was mostly programmed by ChatGPT ## 2025-09-01 and therefore might contain errors and mistakes. @@ -428,7 +430,7 @@ class TagDialog: self.label.pack(padx=(0,0), pady=(30,0)) self.label = tk.Label(self.root, font="bold", text=existingtags) self.label.pack(pady=(0,30)) - + self.label = tk.Label(self.root, fg=low_contrast_fg_color, text=hint_str) self.label.pack(pady=(0,0)) self.label = tk.Label(self.root, text='\n'.join(tag_list)) @@ -484,15 +486,15 @@ class TagDialog: self.submit_button = tk.Button(self.root, text="Tag!", command=self.submit_tags) self.submit_button.pack(side=tk.RIGHT, padx=(20,30), pady=20) - + def on_keyrelease(self, event): """ Handle key release to filter the word completions. """ - + ## Warning: this function was mostly programmed by ChatGPT ## 2025-09-01 and therefore might contain errors and mistakes. ## This needs to be re-checked by somebody with Tkinter ## knowledge. - + user_input = self.entry.get().strip() # Split the input into words @@ -539,7 +541,7 @@ class TagDialog: if len(matching_tags) > 1: # Find the longest common prefix common_prefix = self.longest_common_prefix(matching_tags) - + # Update the entry field with the common prefix new_input = user_input[:len(user_input) - len(current_word)] + common_prefix self.entry.delete(0, tk.END) @@ -596,7 +598,7 @@ class TagDialog: ## 2025-09-01 and therefore might contain errors and mistakes. ## This needs to be re-checked by somebody with Tkinter ## knowledge. - + if not words: return "" @@ -610,7 +612,7 @@ class TagDialog: prefix = prefix[:-1] if not prefix: return "" - + return prefix def submit_tags(self): @@ -638,7 +640,7 @@ class TagDialog: def on_cancel(self): # Just close the dialog self.cancelled = True - self.root.destroy() + self.root.destroy() def contains_tag(filename, tagname=False): @@ -1131,7 +1133,7 @@ def split_up_filename(filename, exception_on_file_not_found=False): """ # logging.debug(f"split_up_filename: called with: {filename= } {exception_on_file_not_found= }") - + if not os.path.exists(filename): # This does make sense for splitting up filenames that are about to be created for example: if exception_on_file_not_found: @@ -1303,7 +1305,7 @@ def create_link(source, destination): If the destination file exists, an error is shown unless the --overwrite option is used which results in deleting the old file and replacing with the new link. - + @param source: a file name of the source, an existing file @param destination: a file name for the link which is about to be created @@ -1319,7 +1321,7 @@ def create_link(source, destination): logging.debug('destination exists and overwrite flag is not set → report error to user') error_exit(21, 'Trying to create new link but found an old file with same name. ' + 'If you want me to overwrite older files, use the "--overwrite" option. Culprit: ' + destination) - + if IS_WINDOWS: # do lnk-files instead of symlinks: shell = win32com.client.Dispatch('WScript.Shell') @@ -2002,7 +2004,7 @@ def locate_file_in_cwd_and_parent_directories(startfile, filename): os.chdir(original_dir) return filename_to_look_for parent_dir = os.path.abspath(os.path.join(os.getcwd(), os.pardir)) - + os.chdir(original_dir) logging.debug('locate_file_in_cwd_and_parent_directories: did NOT find \"%s\" in current directory or any parent directory' % filename) @@ -2151,7 +2153,7 @@ def get_tag_shortcut_information(tag_list, tags_get_added=True, tags_get_linked= @param tags_get_added: True if tags get added, False otherwise @param return: - """ - + if tags_get_added: if len(tag_list) < 9: hint_str = "Previously used tags in this directory:" @@ -2173,9 +2175,9 @@ def get_tag_shortcut_information(tag_list, tags_get_added=True, tags_get_linked= for tag in tag_list: list_of_tag_hints.append(tag + ' (' + str(count) + ')') count += 1 - + return hint_str, list_of_tag_hints - + def print_tag_shortcut_with_numbers(hint_str, tag_list): """A list of tags from the list are printed to stdout. Each tag gets a number associated which corresponds to the position in the @@ -2314,7 +2316,7 @@ def ask_for_tags_text_version(vocabulary, upto9_tags_for_shortcuts, hint_str, ta @param upto9_tags_for_shortcuts: array of tags which can be used to generate number-shortcuts @param return: list of up to top nine keys according to the rank of their values """ - + completionhint = '' if vocabulary and len(vocabulary) > 0: @@ -2382,11 +2384,11 @@ def ask_for_tags_gui_version(vocabulary, upto9_tags_for_shortcuts, hint_str, tag @param upto9_tags_for_shortcuts: array of tags which can be used to generate number-shortcuts @param return: list of up to top nine keys according to the rank of their values """ - + completionhint = '' if vocabulary and len(vocabulary) > 0: assert(vocabulary.__class__ == list) - + number_of_files = len(options.files) number_of_files_str = str(number_of_files) logging.debug("len(files) [%s]" % number_of_files_str) @@ -2527,6 +2529,7 @@ def assert_empty_tagfilter_directory(directory): if not options.dryrun: safe_import('shutil') # for removing directories with shutil.rmtree() shutil.rmtree(directory) + force_rmtree(directory) logging.debug('re-creating tagfilter directory "%s" ...' % str(directory)) os.makedirs(directory) @@ -2972,7 +2975,7 @@ def main(): handle_logging() logging.debug(f'{options=}') - + if options.verbose and options.quiet: error_exit(1, "Options \"--verbose\" and \"--quiet\" found. " + "This does not make any sense, you silly fool :-)") @@ -2988,7 +2991,7 @@ def main(): if not options.interactive and options.gui: logging.warning('Found option "--gui" without option "--interactive". Will ignore that.') - + if options.list_tags_by_number and options.list_tags_by_alphabet: error_exit(6, "Please use only one list-by-option at once.") @@ -3284,6 +3287,29 @@ def main(): successful_exit() +def force_rmtree(path): + """Provide a rmtree compatible both for Linux/MacOS and Windows. + + Previous definitions worked well for Linux/MacOS and their tests + by `unit_tests.py` launched by `pytest` passed with GitHub's + osrunners. However, the very same tests constantly failed with + the osrunner of Windows. After discussion, this function was + provided by Claude AI/Sonnet 4.6.""" + import stat, tempfile + def _remove_readonly(func, fpath, _exc): + os.chmod(fpath, stat.S_IWRITE) + func(fpath) + # Windows locks the CWD; move away before attempting deletion + try: + cwd = os.getcwd() + if os.path.abspath(cwd).startswith(os.path.abspath(path)): + os.chdir(tempfile.gettempdir()) + except Exception: + pass + if os.path.isdir(path): + rmtree(path, onerror=_remove_readonly) + + if __name__ == "__main__": try: main() diff --git a/tests/call_tests.sh b/tests/call_tests.sh index 2ad3026..7df697b 100755 --- a/tests/call_tests.sh +++ b/tests/call_tests.sh @@ -1,5 +1,5 @@ #!/bin/sh cd $(dirname $0)/.. -PYTHONPATH=".:" tests/unit_tests.py --verbose +PYTHONPATH=".:" tests/test_unit_tests.py --verbose #end diff --git a/tests/unit_tests.py b/tests/test_unit_tests.py similarity index 96% rename from tests/unit_tests.py rename to tests/test_unit_tests.py index 53bf140..a2db66d 100755 --- a/tests/unit_tests.py +++ b/tests/test_unit_tests.py @@ -1,3 +1,4 @@ + #!/usr/bin/env python3 # -*- coding: utf-8 -*- # Time-stamp: <2023-08-28 18:16:44 vk> @@ -5,16 +6,17 @@ # invoke tests using following command line: # ~/src/vktag % PYTHONPATH="~/src/filetags:" tests/unit_tests.py --verbose -import unittest -import os -import filetags -import tempfile -import os.path import logging +import os +import os.path import platform +import sys +import tempfile import time # for sleep() +import unittest from shutil import rmtree +import filetags # TEMPLATE for debugging: # try: @@ -416,8 +418,8 @@ class TestLocateAndParseControlledVocabulary(unittest.TestCase): # Note: cwd = subdir3 # Let's create all missing files in all dirs: - filetags.create_link(self.subdir2_file, self.subdir1_file) # create link - filetags.create_link(self.subdir2b_file, self.tempdir_file) # create link + filetags.create_link(self.subdir2_file, self.subdir1_file + ".lnk") # create link + filetags.create_link(self.subdir2b_file, self.tempdir_file + ".lnk") # create link # prio 1 = .filetag file in startfile directory self.assertEqual(filetags.locate_and_parse_controlled_vocabulary(self.subdir1_test_file), @@ -865,7 +867,7 @@ class TestHierarchyWithFilesAndFolders(unittest.TestCase): filetags.handle_file(os.path.join(self.tempdir, 'sub dir 1', 'foo4 -- bar.txt'), ['testtag'], do_remove=False, do_filter=False, dryrun=False) self.assertEqual(self.file_exists(os.path.join(self.tempdir, 'sub dir 1', 'foo4 -- bar testtag.txt')), True) - + def test_tagtrees_with_tagfilter_and_no_filtertag(self): filetags.generate_tagtrees(directory=self.subdir2, @@ -878,16 +880,34 @@ class TestHierarchyWithFilesAndFolders(unittest.TestCase): self.assertEqual(len(os.listdir(self.subdir2)), 5) # 5 entries in this directory self.assertTrue(os.path.isdir(os.path.join(self.subdir2, 'bar'))) - self.assertEqual(set(os.listdir(os.path.join(self.subdir2, 'bar'))), + + if sys.platform != "win32": + self.assertEqual(set(os.listdir(os.path.join(self.subdir2, 'bar'))), set(['baz', 'foo1 -- bar.txt', 'foo2 -- bar baz.txt'])) + if sys.platform == "win32": + self.assertEqual(set(os.listdir(os.path.join(self.subdir2, 'bar'))), + set(['baz', 'foo1 -- bar.txt.lnk', 'foo2 -- bar baz.txt.lnk'])) + self.assertTrue(os.path.isdir(os.path.join(self.subdir2, 'baz'))) - self.assertEqual(set(os.listdir(os.path.join(self.subdir2, 'baz'))), + + if sys.platform != "win32": + self.assertEqual(set(os.listdir(os.path.join(self.subdir2, 'baz'))), set(['bar', 'teststring1', 'foo2 -- bar baz.txt', 'foo3 -- baz teststring1.txt'])) + if sys.platform == "win32": + self.assertEqual(set(os.listdir(os.path.join(self.subdir2, 'baz'))), + set(['bar', 'teststring1', 'foo2 -- bar baz.txt.lnk', 'foo3 -- baz teststring1.txt.lnk'])) + self.assertTrue(os.path.isdir(os.path.join(self.subdir2, 'teststring1'))) - self.assertEqual(set(os.listdir(os.path.join(self.subdir2, 'teststring1'))), + + if sys.platform != "win32": + self.assertEqual(set(os.listdir(os.path.join(self.subdir2, 'teststring1'))), set(['baz', 'foo3 -- baz teststring1.txt'])) + if sys.platform == "win32": + self.assertEqual(set(os.listdir(os.path.join(self.subdir2, 'teststring1'))), + set(['baz', 'foo3 -- baz teststring1.txt.lnk'])) + self.assertTrue(os.path.isdir(os.path.join(self.subdir2, 'nontagged_items'))) @@ -906,12 +926,23 @@ class TestHierarchyWithFilesAndFolders(unittest.TestCase): self.assertFalse(os.path.isdir(os.path.join(self.subdir2, 'bar'))) self.assertTrue(os.path.isdir(os.path.join(self.subdir2, 'baz'))) - self.assertEqual(set(os.listdir(os.path.join(self.subdir2, 'baz'))), + + if sys.platform != "win32": + self.assertEqual(set(os.listdir(os.path.join(self.subdir2, 'baz'))), set(['teststring1', 'foo3 -- baz teststring1.txt'])) + elif sys.platform == "win32": + self.assertEqual(set(os.listdir(os.path.join(self.subdir2, 'baz'))), + set(['teststring1', 'foo3 -- baz teststring1.txt.lnk'])) + self.assertTrue(os.path.isdir(os.path.join(self.subdir2, 'teststring1'))) - self.assertEqual(set(os.listdir(os.path.join(self.subdir2, 'teststring1'))), + + if sys.platform != "win32": + self.assertEqual(set(os.listdir(os.path.join(self.subdir2, 'teststring1'))), set(['baz', 'foo3 -- baz teststring1.txt'])) + if sys.platform == "win32": + self.assertEqual(set(os.listdir(os.path.join(self.subdir2, 'teststring1'))), + set(['baz', 'foo3 -- baz teststring1.txt.lnk'])) self.assertTrue(os.path.isdir(os.path.join(self.subdir2, 'nontagged_items'))) @@ -931,8 +962,13 @@ class TestHierarchyWithFilesAndFolders(unittest.TestCase): self.assertFalse(os.path.isdir(os.path.join(self.subdir2, 'bar'))) self.assertTrue(os.path.isdir(os.path.join(self.subdir2, 'baz'))) - self.assertEqual(set(os.listdir(os.path.join(self.subdir2, 'baz'))), + + if sys.platform != "win32": + self.assertEqual(set(os.listdir(os.path.join(self.subdir2, 'baz'))), set(['teststring1', 'foo3 -- baz teststring1.txt'])) + if sys.platform == "win32": + self.assertEqual(set(os.listdir(os.path.join(self.subdir2, 'baz'))), + set(['teststring1', 'foo3 -- baz teststring1.txt.lnk'])) self.assertTrue(os.path.isdir(os.path.join(self.subdir2, 'teststring1')))