From c7691fff2321b3f92bdbaf96d589c9e394468339 Mon Sep 17 00:00:00 2001 From: Norwid Behrnd Date: Fri, 27 Feb 2026 17:57:59 +0100 Subject: [PATCH 1/4] test(unit_tests.py): additional Windows clauses Apparently, the organization of linkfiles in Windows differs from the one in Linux; and much more, than thought earlier. In addition to this, the additional `.lnk` is visible only in a shell (git BASH; Windows' `cmd.exe`, or PowerShell) however not in the default GUI filemanager Windows is shipped (regardless if the user opts-in to display file extensions like `.txt`, or not). The edit of this commit thus only is a starter of additional checks and likely similar edits to the source code. Signed-off-by: Norwid Behrnd --- tests/unit_tests.py | 51 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 43 insertions(+), 8 deletions(-) diff --git a/tests/unit_tests.py b/tests/unit_tests.py index 53bf140..99b210f 100755 --- a/tests/unit_tests.py +++ b/tests/unit_tests.py @@ -1,3 +1,4 @@ + #!/usr/bin/env python3 # -*- coding: utf-8 -*- # Time-stamp: <2023-08-28 18:16:44 vk> @@ -14,7 +15,7 @@ import logging import platform import time # for sleep() from shutil import rmtree - +import sys # TEMPLATE for debugging: # try: @@ -865,7 +866,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 +879,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 +925,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 +961,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'))) From 7ca34e04a0fc7927580f1cb168231e182b160056 Mon Sep 17 00:00:00 2001 From: Norwid Behrnd Date: Mon, 2 Mar 2026 12:42:30 +0100 Subject: [PATCH 2/4] fix(__init__.py): edit tree release in Windows Previously, tests about the deletion of tagtrees in GitHub's Windows osrunner failed, while the same tests in the runners of Ubuntu an MacOS passed. This likely is due how these files are "released" for deletion with greter ease (Linux/MacOS), or not (Windows). This is addressed by function `force_rmtree` added to `__init__.py`, result of a discussion with Claude AI/Sonnet 4.6. Simultaneously, this commit corrects the addition of the file extension `.lnk` in one of the tests. When submitted to the check by `ci_pytest.yml`, now each unit test compiled in `unit_tests.py` passes with Python 3.14 and either osrunner of Ubuntu 24.04.3, Microsoft Windows Server 2025 / 10.0.26100, and macOS 15.7.4 GitHub currently provides as ubuntu-latest, windows-latest, macos-latest. Signed-off-by: Norwid Behrnd --- filetags/__init__.py | 27 +++++++++++++++++++++++++++ tests/unit_tests.py | 4 ++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/filetags/__init__.py b/filetags/__init__.py index ceec5b9..ab79464 100755 --- a/filetags/__init__.py +++ b/filetags/__init__.py @@ -53,6 +53,9 @@ try: except ModuleNotFoundError: have_tkinter = False +import stat +import tempfile + safe_import('operator') # for sorting dicts safe_import('difflib') # for good enough matching words safe_import('readline') # for raw_input() reading from stdin @@ -2521,6 +2524,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) @@ -3278,6 +3282,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/unit_tests.py b/tests/unit_tests.py index 99b210f..58c6ea2 100755 --- a/tests/unit_tests.py +++ b/tests/unit_tests.py @@ -417,8 +417,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), From e8aa40638cffafb4e3b254984e8746505207423c Mon Sep 17 00:00:00 2001 From: Norwid Behrnd Date: Mon, 2 Mar 2026 13:07:38 +0100 Subject: [PATCH 3/4] ci: git mv unit_tests.py tests_unit_tests.py For an easier launch of the unit tests by `pytest`, file `unit_tests.py` was renamed and thus now is discovered automatically from the root of the project. Signed-off-by: Norwid Behrnd --- .github/workflows/ci_pytest.yml | 5 ++--- tests/call_tests.sh | 2 +- tests/{unit_tests.py => test_unit_tests.py} | 0 3 files changed, 3 insertions(+), 4 deletions(-) rename tests/{unit_tests.py => test_unit_tests.py} (100%) 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/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 100% rename from tests/unit_tests.py rename to tests/test_unit_tests.py From 99e8b8e752169a453761423179e16485f2733c35 Mon Sep 17 00:00:00 2001 From: Norwid Behrnd Date: Mon, 2 Mar 2026 13:19:48 +0100 Subject: [PATCH 4/4] style(__init__.py, test_unit_tests.py): isort imports To easier track of the packages used, the imports are isort like sorted. Signed-off-by: Norwid Behrnd --- filetags/__init__.py | 67 ++++++++++++++++++++-------------------- tests/test_unit_tests.py | 15 ++++----- 2 files changed, 41 insertions(+), 41 deletions(-) diff --git a/filetags/__init__.py b/filetags/__init__.py index ab79464..78f6332 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 ## for --gui @@ -53,9 +55,6 @@ try: except ModuleNotFoundError: have_tkinter = False -import stat -import tempfile - safe_import('operator') # for sorting dicts safe_import('difflib') # for good enough matching words safe_import('readline') # for raw_input() reading from stdin @@ -378,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. @@ -431,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)) @@ -487,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 @@ -542,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) @@ -599,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 "" @@ -613,7 +612,7 @@ class TagDialog: prefix = prefix[:-1] if not prefix: return "" - + return prefix def submit_tags(self): @@ -641,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): @@ -1134,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: @@ -1306,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 @@ -1322,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') @@ -2005,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) @@ -2154,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:" @@ -2176,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 @@ -2317,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: @@ -2385,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) @@ -2970,7 +2969,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 :-)") @@ -2986,7 +2985,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.") diff --git a/tests/test_unit_tests.py b/tests/test_unit_tests.py index 58c6ea2..a2db66d 100755 --- a/tests/test_unit_tests.py +++ b/tests/test_unit_tests.py @@ -6,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 time # for sleep() -from shutil import rmtree import sys +import tempfile +import time # for sleep() +import unittest +from shutil import rmtree + +import filetags # TEMPLATE for debugging: # try: