diff --git a/src/Mod/AddonManager/AddonManager.py b/src/Mod/AddonManager/AddonManager.py
index 3d11538c2b..16ebdd6ceb 100644
--- a/src/Mod/AddonManager/AddonManager.py
+++ b/src/Mod/AddonManager/AddonManager.py
@@ -1026,8 +1026,8 @@ class CommandAddonManager:
self.dialog,
translate("AddonsInstaller", "Confirm remove"),
translate(
- "AddonsInstaller", "Are you sure you want to uninstall this Addon?"
- ),
+ "AddonsInstaller", "Are you sure you want to uninstall {}?"
+ ).format(repo.display_name),
QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.Cancel,
)
if confirm == QtWidgets.QMessageBox.Cancel:
diff --git a/src/Mod/AddonManager/AddonManagerTest/app/mocks.py b/src/Mod/AddonManager/AddonManagerTest/app/mocks.py
new file mode 100644
index 0000000000..7a878be6b7
--- /dev/null
+++ b/src/Mod/AddonManager/AddonManagerTest/app/mocks.py
@@ -0,0 +1,74 @@
+# ***************************************************************************
+# * *
+# * Copyright (c) 2022 FreeCAD Project Association *
+# * *
+# * This file is part of FreeCAD. *
+# * *
+# * FreeCAD is free software: you can redistribute it and/or modify it *
+# * under the terms of the GNU Lesser General Public License as *
+# * published by the Free Software Foundation, either version 2.1 of the *
+# * License, or (at your option) any later version. *
+# * *
+# * FreeCAD is distributed in the hope that it will be useful, but *
+# * WITHOUT ANY WARRANTY; without even the implied warranty of *
+# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
+# * Lesser General Public License for more details. *
+# * *
+# * You should have received a copy of the GNU Lesser General Public *
+# * License along with FreeCAD. If not, see *
+# * . *
+# * *
+# ***************************************************************************
+
+"""Mock objects for use when testing the addon manager non-GUI code."""
+
+import os
+import shutil
+
+
+class MockAddon:
+ """Minimal Addon class"""
+
+ def __init__(self):
+ self.name = "TestAddon"
+ self.url = "https://github.com/FreeCAD/FreeCAD-addons"
+ self.branch = "master"
+ self.macro = None
+
+
+class MockMacro:
+ """Minimal Macro class"""
+
+ def __init__(self):
+ self.name = "MockMacro"
+ self.filename = self.name + ".FCMacro"
+ self.icon = "" # If set, should just be fake filename, doesn't have to exist
+ self.xpm = ""
+ self.other_files = [] # If set, should be fake names, don't have to exist
+
+ def install(self, location: os.PathLike):
+ """Installer function for the mock macro object: creates a file with the src_filename
+ attribute, and optionally an icon, xpm, and other_files. The data contained in these files
+ is not usable and serves only as a placeholder for the existence of the files."""
+
+ with open(
+ os.path.join(location, self.filename),
+ "w",
+ encoding="utf-8",
+ ) as f:
+ f.write("Test file for macro installation unit tests")
+ if self.icon:
+ with open(os.path.join(location, self.icon), "wb") as f:
+ f.write(b"Fake icon data - nothing to see here\n")
+ if self.xpm:
+ with open(
+ os.path.join(location, "MockMacro_icon.xpm"), "w", encoding="utf-8"
+ ) as f:
+ f.write(self.xpm)
+ for name in self.other_files:
+ if "/" in name:
+ new_location = os.path.dirname(os.path.join(location, name))
+ os.makedirs(new_location, exist_ok=True)
+ with open(os.path.join(location, name), "w", encoding="utf-8") as f:
+ f.write("# Fake macro data for unit testing\n")
+ return True, []
diff --git a/src/Mod/AddonManager/AddonManagerTest/app/test_installer.py b/src/Mod/AddonManager/AddonManagerTest/app/test_installer.py
index 185262f2b5..30bbda80d3 100644
--- a/src/Mod/AddonManager/AddonManagerTest/app/test_installer.py
+++ b/src/Mod/AddonManager/AddonManagerTest/app/test_installer.py
@@ -38,12 +38,7 @@ from addonmanager_git import GitManager, initialize_git
from Addon import Addon
-
-class MockAddon:
- def __init__(self):
- self.name = "TestAddon"
- self.url = "https://github.com/FreeCAD/FreeCAD-addons"
- self.branch = "master"
+from AddonManagerTest.app.mocks import MockAddon, MockMacro
class TestAddonInstaller(unittest.TestCase):
@@ -359,28 +354,21 @@ class TestMacroInstaller(unittest.TestCase):
MODULE = "test_installer" # file name without extension
def setUp(self):
- class MacroMock:
- def install(self, location: os.PathLike):
- with open(
- os.path.join(location, "MACRO_INSTALLATION_TEST"),
- "w",
- encoding="utf-8",
- ) as f:
- f.write("Test file for macro installation unit tests")
- return True, []
+ """Set up the mock objects"""
- class AddonMock:
- def __init__(self):
- self.macro = MacroMock()
-
- self.mock = AddonMock()
+ self.mock = MockAddon()
+ self.mock.macro = MockMacro()
def test_installation(self):
+ """Test the wrapper around the macro installer"""
+
+ # Note that this doesn't test the underlying Macro object's install function, it only
+ # tests whether that function is called appropriately by the MacroInstaller wrapper.
with tempfile.TemporaryDirectory() as temp_dir:
installer = MacroInstaller(self.mock)
installer.installation_path = temp_dir
installation_succeeded = installer.run()
self.assertTrue(installation_succeeded)
self.assertTrue(
- os.path.exists(os.path.join(temp_dir, "MACRO_INSTALLATION_TEST"))
+ os.path.exists(os.path.join(temp_dir, self.mock.macro.filename))
)
diff --git a/src/Mod/AddonManager/AddonManagerTest/app/test_uninstaller.py b/src/Mod/AddonManager/AddonManagerTest/app/test_uninstaller.py
new file mode 100644
index 0000000000..6c775136ad
--- /dev/null
+++ b/src/Mod/AddonManager/AddonManagerTest/app/test_uninstaller.py
@@ -0,0 +1,495 @@
+# ***************************************************************************
+# * *
+# * Copyright (c) 2022 FreeCAD Project Association *
+# * *
+# * This file is part of FreeCAD. *
+# * *
+# * FreeCAD is free software: you can redistribute it and/or modify it *
+# * under the terms of the GNU Lesser General Public License as *
+# * published by the Free Software Foundation, either version 2.1 of the *
+# * License, or (at your option) any later version. *
+# * *
+# * FreeCAD is distributed in the hope that it will be useful, but *
+# * WITHOUT ANY WARRANTY; without even the implied warranty of *
+# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
+# * Lesser General Public License for more details. *
+# * *
+# * You should have received a copy of the GNU Lesser General Public *
+# * License along with FreeCAD. If not, see *
+# * . *
+# * *
+# ***************************************************************************
+
+"""Contains the unit test class for addonmanager_uninstaller.py non-GUI functionality."""
+
+import functools
+import os
+from stat import S_IREAD, S_IRGRP, S_IROTH, S_IWUSR
+import tempfile
+import unittest
+
+import FreeCAD
+
+from addonmanager_uninstaller import AddonUninstaller, MacroUninstaller
+
+from Addon import Addon
+from AddonManagerTest.app.mocks import MockAddon, MockMacro
+
+
+class TestAddonUninstaller(unittest.TestCase):
+ """Test class for addonmanager_uninstaller.py non-GUI functionality"""
+
+ MODULE = "test_uninstaller" # file name without extension
+
+ def setUp(self):
+ """Initialize data needed for all tests"""
+ self.test_data_dir = os.path.join(
+ FreeCAD.getHomePath(), "Mod", "AddonManager", "AddonManagerTest", "data"
+ )
+ self.mock_addon = MockAddon()
+ self.signals_caught = []
+ self.test_object = AddonUninstaller(self.mock_addon)
+
+ self.test_object.finished.connect(
+ functools.partial(self.catch_signal, "finished")
+ )
+ self.test_object.success.connect(
+ functools.partial(self.catch_signal, "success")
+ )
+ self.test_object.failure.connect(
+ functools.partial(self.catch_signal, "failure")
+ )
+
+ def tearDown(self):
+ """Finalize the test."""
+
+ def catch_signal(self, signal_name, *_):
+ """Internal use: used to catch and log any emitted signals. Not called directly."""
+ self.signals_caught.append(signal_name)
+
+ def setup_dummy_installation(self, temp_dir) -> str:
+ """Set up a dummy Addon in temp_dir"""
+ toplevel_path = os.path.join(temp_dir, self.mock_addon.name)
+ os.makedirs(toplevel_path)
+ with open(os.path.join(toplevel_path, "README.md"), "w") as f:
+ f.write("## Mock Addon ##\n\nFile created by the unit test code.")
+ self.test_object.installation_path = temp_dir
+ return toplevel_path
+
+ def create_fake_macro(self, macro_directory, fake_macro_name, digest):
+ """Create an FCMacro file and matching digest entry for later removal"""
+ os.makedirs(macro_directory, exist_ok=True)
+ fake_file_installed = os.path.join(macro_directory, fake_macro_name)
+ with open(digest, "a", encoding="utf-8") as f:
+ f.write("# The following files were created outside this installation:\n")
+ f.write(fake_file_installed + "\n")
+ with open(fake_file_installed, "w", encoding="utf-8") as f:
+ f.write("# Fake macro data for unit testing")
+
+ def test_uninstall_normal(self):
+ """Test the integrated uninstall function under normal circumstances"""
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ toplevel_path = self.setup_dummy_installation(temp_dir)
+ self.test_object.run()
+ self.assertTrue(os.path.exists(temp_dir))
+ self.assertFalse(os.path.exists(toplevel_path))
+ self.assertNotIn("failure", self.signals_caught)
+ self.assertIn("success", self.signals_caught)
+ self.assertIn("finished", self.signals_caught)
+
+ def test_uninstall_no_name(self):
+ """Test the integrated uninstall function for an addon without a name"""
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ toplevel_path = self.setup_dummy_installation(temp_dir)
+ self.mock_addon.name = None
+ result = self.test_object.run()
+ self.assertTrue(os.path.exists(temp_dir))
+ self.assertIn("failure", self.signals_caught)
+ self.assertNotIn("success", self.signals_caught)
+ self.assertIn("finished", self.signals_caught)
+
+ def test_uninstall_dangerous_name(self):
+ """Test the integrated uninstall function for an addon with a dangerous name"""
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ toplevel_path = self.setup_dummy_installation(temp_dir)
+ self.mock_addon.name = "./"
+ result = self.test_object.run()
+ self.assertTrue(os.path.exists(temp_dir))
+ self.assertIn("failure", self.signals_caught)
+ self.assertNotIn("success", self.signals_caught)
+ self.assertIn("finished", self.signals_caught)
+
+ def test_uninstall_unmatching_name(self):
+ """Test the integrated uninstall function for an addon with a name that isn't installed"""
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ toplevel_path = self.setup_dummy_installation(temp_dir)
+ self.mock_addon.name += "Nonexistent"
+ result = self.test_object.run()
+ self.assertTrue(os.path.exists(temp_dir))
+ self.assertIn("failure", self.signals_caught)
+ self.assertNotIn("success", self.signals_caught)
+ self.assertIn("finished", self.signals_caught)
+
+ def test_uninstall_addon_with_macros(self):
+ """Tests that the uninstaller removes the macro files"""
+ with tempfile.TemporaryDirectory() as temp_dir:
+ toplevel_path = self.setup_dummy_installation(temp_dir)
+ macro_directory = os.path.join(temp_dir, "Macros")
+ self.create_fake_macro(
+ macro_directory,
+ "FakeMacro.FCMacro",
+ os.path.join(toplevel_path, "AM_INSTALLATION_DIGEST.txt"),
+ )
+ result = self.test_object.run()
+ self.assertNotIn("failure", self.signals_caught)
+ self.assertIn("success", self.signals_caught)
+ self.assertIn("finished", self.signals_caught)
+ self.assertFalse(
+ os.path.exists(os.path.join(macro_directory, "FakeMacro.FCMacro"))
+ )
+ self.assertTrue(os.path.exists(macro_directory))
+
+ def test_uninstall_calls_script(self):
+ """Tests that the main uninstaller run function calls the uninstall.py script"""
+
+ class Interceptor:
+ def __init__(self):
+ self.called = False
+ self.args = []
+
+ def func(self, *args):
+ self.called = True
+ self.args = args
+
+ interceptor = Interceptor()
+ with tempfile.TemporaryDirectory() as temp_dir:
+ toplevel_path = self.setup_dummy_installation(temp_dir)
+ self.test_object.run_uninstall_script = interceptor.func
+ result = self.test_object.run()
+ self.assertTrue(interceptor.called, "Failed to call uninstall script")
+
+ def test_remove_extra_files_no_digest(self):
+ """Tests that a lack of digest file is not an error, and nothing gets removed"""
+ with tempfile.TemporaryDirectory() as temp_dir:
+ self.test_object.remove_extra_files(temp_dir) # Shouldn't throw
+ self.assertTrue(os.path.exists(temp_dir))
+
+ def test_remove_extra_files_empty_digest(self):
+ """Test that an empty digest file is not an error, and nothing gets removed"""
+ with tempfile.TemporaryDirectory() as temp_dir:
+ with open("AM_INSTALLATION_DIGEST.txt", "w", encoding="utf-8") as f:
+ f.write("")
+ self.test_object.remove_extra_files(temp_dir) # Shouldn't throw
+ self.assertTrue(os.path.exists(temp_dir))
+
+ def test_remove_extra_files_comment_only_digest(self):
+ """Test that a digest file that contains only comment lines is not an error, and nothing
+ gets removed"""
+ with tempfile.TemporaryDirectory() as temp_dir:
+ with open("AM_INSTALLATION_DIGEST.txt", "w", encoding="utf-8") as f:
+ f.write("# Fake digest file for unit testing")
+ self.test_object.remove_extra_files(temp_dir) # Shouldn't throw
+ self.assertTrue(os.path.exists(temp_dir))
+
+ def test_remove_extra_files_repeated_files(self):
+ """Test that a digest with the same file repeated removes it once, but doesn't error on
+ later requests to remove it."""
+ with tempfile.TemporaryDirectory() as temp_dir:
+ toplevel_path = self.setup_dummy_installation(temp_dir)
+ macro_directory = os.path.join(temp_dir, "Macros")
+ self.create_fake_macro(
+ macro_directory,
+ "FakeMacro.FCMacro",
+ os.path.join(toplevel_path, "AM_INSTALLATION_DIGEST.txt"),
+ )
+ self.create_fake_macro(
+ macro_directory,
+ "FakeMacro.FCMacro",
+ os.path.join(toplevel_path, "AM_INSTALLATION_DIGEST.txt"),
+ )
+ self.create_fake_macro(
+ macro_directory,
+ "FakeMacro.FCMacro",
+ os.path.join(toplevel_path, "AM_INSTALLATION_DIGEST.txt"),
+ )
+ self.test_object.remove_extra_files(toplevel_path) # Shouldn't throw
+ self.assertFalse(
+ os.path.exists(os.path.join(macro_directory, "FakeMacro.FCMacro"))
+ )
+
+ def test_remove_extra_files_normal_case(self):
+ """Test that a digest that is a "normal" case removes the requested files"""
+ with tempfile.TemporaryDirectory() as temp_dir:
+ toplevel_path = self.setup_dummy_installation(temp_dir)
+ macro_directory = os.path.join(temp_dir, "Macros")
+ self.create_fake_macro(
+ macro_directory,
+ "FakeMacro1.FCMacro",
+ os.path.join(toplevel_path, "AM_INSTALLATION_DIGEST.txt"),
+ )
+ self.create_fake_macro(
+ macro_directory,
+ "FakeMacro2.FCMacro",
+ os.path.join(toplevel_path, "AM_INSTALLATION_DIGEST.txt"),
+ )
+ self.create_fake_macro(
+ macro_directory,
+ "FakeMacro3.FCMacro",
+ os.path.join(toplevel_path, "AM_INSTALLATION_DIGEST.txt"),
+ )
+
+ # Make sure the setup worked as expected, otherwise the test is meaningless
+ self.assertTrue(
+ os.path.exists(os.path.join(macro_directory, "FakeMacro1.FCMacro"))
+ )
+ self.assertTrue(
+ os.path.exists(os.path.join(macro_directory, "FakeMacro2.FCMacro"))
+ )
+ self.assertTrue(
+ os.path.exists(os.path.join(macro_directory, "FakeMacro3.FCMacro"))
+ )
+
+ self.test_object.remove_extra_files(toplevel_path) # Shouldn't throw
+
+ self.assertFalse(
+ os.path.exists(os.path.join(macro_directory, "FakeMacro1.FCMacro"))
+ )
+ self.assertFalse(
+ os.path.exists(os.path.join(macro_directory, "FakeMacro2.FCMacro"))
+ )
+ self.assertFalse(
+ os.path.exists(os.path.join(macro_directory, "FakeMacro3.FCMacro"))
+ )
+
+ def test_runs_uninstaller_script_successful(self):
+ """Tests that the uninstall.py script is called"""
+ with tempfile.TemporaryDirectory() as temp_dir:
+ toplevel_path = self.setup_dummy_installation(temp_dir)
+ with open(
+ os.path.join(toplevel_path, "uninstall.py"), "w", encoding="utf-8"
+ ) as f:
+ double_escaped = temp_dir.replace("\\", "\\\\")
+ f.write(
+ f"""# Mock uninstaller script
+import os
+path = '{double_escaped}'
+with open(os.path.join(path,"RAN_UNINSTALLER.txt"),"w",encoding="utf-8") as f:
+ f.write("File created by uninstall.py from unit tests")
+"""
+ )
+ self.test_object.run_uninstall_script(
+ toplevel_path
+ ) # The exception does not leak out
+ self.assertTrue(
+ os.path.exists(os.path.join(temp_dir, "RAN_UNINSTALLER.txt"))
+ )
+
+ def test_runs_uninstaller_script_failure(self):
+ """Tests that exceptions in the uninstall.py script do not leak out"""
+ with tempfile.TemporaryDirectory() as temp_dir:
+ toplevel_path = self.setup_dummy_installation(temp_dir)
+ with open(
+ os.path.join(toplevel_path, "uninstall.py"), "w", encoding="utf-8"
+ ) as f:
+ f.write(
+ f"""# Mock uninstaller script
+raise RuntimeError("Fake exception for unit testing")
+"""
+ )
+ self.test_object.run_uninstall_script(
+ toplevel_path
+ ) # The exception does not leak out
+
+
+class TestMacroUninstaller(unittest.TestCase):
+ """Test class for addonmanager_uninstaller.py non-GUI functionality"""
+
+ MODULE = "test_uninstaller" # file name without extension
+
+ def setUp(self):
+ self.mock_addon = MockAddon()
+ self.mock_addon.macro = MockMacro()
+ self.test_object = MacroUninstaller(self.mock_addon)
+ self.signals_caught = []
+
+ self.test_object.finished.connect(
+ functools.partial(self.catch_signal, "finished")
+ )
+ self.test_object.success.connect(
+ functools.partial(self.catch_signal, "success")
+ )
+ self.test_object.failure.connect(
+ functools.partial(self.catch_signal, "failure")
+ )
+
+ def tearDown(self):
+ pass
+
+ def catch_signal(self, signal_name, *_):
+ """Internal use: used to catch and log any emitted signals. Not called directly."""
+ self.signals_caught.append(signal_name)
+
+ def test_remove_simple_macro(self):
+ with tempfile.TemporaryDirectory() as temp_dir:
+ self.test_object.installation_location = temp_dir
+ self.mock_addon.macro.install(temp_dir)
+ # Make sure the setup worked, otherwise the test is meaningless
+ self.assertTrue(
+ os.path.exists(os.path.join(temp_dir, self.mock_addon.macro.filename))
+ )
+ self.test_object.run()
+ self.assertFalse(
+ os.path.exists(os.path.join(temp_dir, self.mock_addon.macro.filename))
+ )
+ self.assertNotIn("failure", self.signals_caught)
+ self.assertIn("success", self.signals_caught)
+ self.assertIn("finished", self.signals_caught)
+
+ def test_remove_macro_with_icon(self):
+ with tempfile.TemporaryDirectory() as temp_dir:
+ self.test_object.installation_location = temp_dir
+ self.mock_addon.macro.icon = "mock_icon_test.svg"
+ self.mock_addon.macro.install(temp_dir)
+ # Make sure the setup worked, otherwise the test is meaningless
+ self.assertTrue(
+ os.path.exists(os.path.join(temp_dir, self.mock_addon.macro.filename))
+ )
+ self.assertTrue(
+ os.path.exists(os.path.join(temp_dir, self.mock_addon.macro.icon))
+ )
+ self.test_object.run()
+ self.assertFalse(
+ os.path.exists(os.path.join(temp_dir, self.mock_addon.macro.filename))
+ )
+ self.assertFalse(
+ os.path.exists(os.path.join(temp_dir, self.mock_addon.macro.icon))
+ )
+ self.assertNotIn("failure", self.signals_caught)
+ self.assertIn("success", self.signals_caught)
+ self.assertIn("finished", self.signals_caught)
+
+ def test_remove_macro_with_xpm_data(self):
+ with tempfile.TemporaryDirectory() as temp_dir:
+ self.test_object.installation_location = temp_dir
+ self.mock_addon.macro.xpm = "/*Fake XPM data*/"
+ self.mock_addon.macro.install(temp_dir)
+ # Make sure the setup worked, otherwise the test is meaningless
+ self.assertTrue(
+ os.path.exists(os.path.join(temp_dir, self.mock_addon.macro.filename))
+ )
+ self.assertTrue(
+ os.path.exists(os.path.join(temp_dir, "MockMacro_icon.xpm"))
+ )
+ self.test_object.run()
+ self.assertFalse(
+ os.path.exists(os.path.join(temp_dir, self.mock_addon.macro.filename))
+ )
+ self.assertFalse(
+ os.path.exists(os.path.join(temp_dir, "MockMacro_icon.xpm"))
+ )
+ self.assertNotIn("failure", self.signals_caught)
+ self.assertIn("success", self.signals_caught)
+ self.assertIn("finished", self.signals_caught)
+
+ def test_remove_macro_with_files(self):
+ with tempfile.TemporaryDirectory() as temp_dir:
+ self.test_object.installation_location = temp_dir
+ self.mock_addon.macro.other_files = [
+ "test_file_1.txt",
+ "test_file_2.FCMacro",
+ "subdir/test_file_3.txt",
+ ]
+ self.mock_addon.macro.install(temp_dir)
+ # Make sure the setup worked, otherwise the test is meaningless
+ for f in self.mock_addon.macro.other_files:
+ self.assertTrue(
+ os.path.exists(os.path.join(temp_dir, f)),
+ f"Expected {f} to exist, and it does not",
+ )
+ self.test_object.run()
+ for f in self.mock_addon.macro.other_files:
+ self.assertFalse(
+ os.path.exists(os.path.join(temp_dir, f)),
+ f"Expected {f} to be removed, and it was not",
+ )
+ self.assertFalse(
+ os.path.exists(os.path.join(temp_dir, "subdir")),
+ "Failed to remove empty subdirectory",
+ )
+ self.assertNotIn("failure", self.signals_caught)
+ self.assertIn("success", self.signals_caught)
+ self.assertIn("finished", self.signals_caught)
+
+ def test_remove_nonexistent_macro(self):
+ with tempfile.TemporaryDirectory() as temp_dir:
+ self.test_object.installation_location = temp_dir
+ # Don't run the installer:
+
+ self.assertFalse(
+ os.path.exists(os.path.join(temp_dir, self.mock_addon.macro.filename))
+ )
+ self.test_object.run() # Should not raise an exception
+ self.assertFalse(
+ os.path.exists(os.path.join(temp_dir, self.mock_addon.macro.filename))
+ )
+ self.assertNotIn("failure", self.signals_caught)
+ self.assertIn("success", self.signals_caught)
+ self.assertIn("finished", self.signals_caught)
+
+ def test_remove_write_protected_macro(self):
+ with tempfile.TemporaryDirectory() as temp_dir:
+ self.test_object.installation_location = temp_dir
+ self.mock_addon.macro.install(temp_dir)
+ # Make sure the setup worked, otherwise the test is meaningless
+ f = os.path.join(temp_dir, self.mock_addon.macro.filename)
+ self.assertTrue(os.path.exists(f))
+ os.chmod(f, S_IREAD | S_IRGRP | S_IROTH)
+ self.test_object.run()
+ os.chmod(f, S_IWUSR | S_IREAD)
+
+ self.assertIn("failure", self.signals_caught)
+ self.assertNotIn("success", self.signals_caught)
+ self.assertIn("finished", self.signals_caught)
+
+ def test_cleanup_directories_multiple_empty(self):
+ with tempfile.TemporaryDirectory() as temp_dir:
+ empty_directories = set(["empty1", "empty2", "empty3"])
+ full_paths = set()
+ for directory in empty_directories:
+ full_path = os.path.join(temp_dir, directory)
+ os.mkdir(full_path)
+ full_paths.add(full_path)
+
+ for directory in full_paths:
+ self.assertTrue(directory, "Test code failed to create {directory}")
+ self.test_object._cleanup_directories(full_paths)
+ for directory in full_paths:
+ self.assertFalse(os.path.exists(directory))
+
+ def test_cleanup_directories_none(self):
+ with tempfile.TemporaryDirectory() as temp_dir:
+ full_paths = set()
+ self.test_object._cleanup_directories(full_paths) # Shouldn't throw
+
+ def test_cleanup_directories_not_empty(self):
+ with tempfile.TemporaryDirectory() as temp_dir:
+ empty_directories = set(["empty1", "empty2", "empty3"])
+ full_paths = set()
+ for directory in empty_directories:
+ full_path = os.path.join(temp_dir, directory)
+ os.mkdir(full_path)
+ full_paths.add(full_path)
+ with open(
+ os.path.join(full_path, "test.txt"), "w", encoding="utf-8"
+ ) as f:
+ f.write("Unit test dummy data\n")
+
+ for directory in full_paths:
+ self.assertTrue(directory, "Test code failed to create {directory}")
+ self.test_object._cleanup_directories(full_paths)
+ for directory in full_paths:
+ self.assertTrue(os.path.exists(directory))
diff --git a/src/Mod/AddonManager/CMakeLists.txt b/src/Mod/AddonManager/CMakeLists.txt
index 0ea4b04e62..829fb8d4be 100644
--- a/src/Mod/AddonManager/CMakeLists.txt
+++ b/src/Mod/AddonManager/CMakeLists.txt
@@ -24,6 +24,7 @@ SET(AddonManager_SRCS
addonmanager_installer_gui.py
addonmanager_macro.py
addonmanager_update_all_gui.py
+ addonmanager_uninstaller.py
addonmanager_utilities.py
addonmanager_workers_installation.py
addonmanager_workers_startup.py
@@ -77,12 +78,14 @@ SET(AddonManagerTests_SRCS
SET(AddonManagerTestsApp_SRCS
AddonManagerTest/app/__init__.py
+ AddonManagerTest/app/mocks.py
AddonManagerTest/app/test_addon.py
AddonManagerTest/app/test_dependency_installer.py
AddonManagerTest/app/test_git.py
AddonManagerTest/app/test_installer.py
AddonManagerTest/app/test_macro.py
AddonManagerTest/app/test_utilities.py
+ AddonManagerTest/app/test_uninstaller.py
)
SET(AddonManagerTestsGui_SRCS
diff --git a/src/Mod/AddonManager/TestAddonManagerApp.py b/src/Mod/AddonManager/TestAddonManagerApp.py
index 0ff6b021d5..af72c6bf84 100644
--- a/src/Mod/AddonManager/TestAddonManagerApp.py
+++ b/src/Mod/AddonManager/TestAddonManagerApp.py
@@ -40,6 +40,10 @@ from AddonManagerTest.app.test_installer import (
from AddonManagerTest.app.test_dependency_installer import (
TestDependencyInstaller as AddonManagerTestDependencyInstaller,
)
+from AddonManagerTest.app.test_uninstaller import (
+ TestAddonUninstaller as AddonManagerTestAddonUninstaller,
+ TestMacroUninstaller as AddonManagerTestMacroUninstaller,
+)
# dummy usage to get flake8 and lgtm quiet
False if AddonManagerTestUtilities.__name__ else True
@@ -49,3 +53,5 @@ False if AddonManagerTestGit.__name__ else True
False if AddonManagerTestAddonInstaller.__name__ else True
False if AddonManagerTestMacroInstaller.__name__ else True
False if AddonManagerTestDependencyInstaller.__name__ else True
+False if AddonManagerTestAddonUninstaller.__name__ else True
+False if AddonManagerTestMacroUninstaller.__name__ else True
diff --git a/src/Mod/AddonManager/addonmanager_macro.py b/src/Mod/AddonManager/addonmanager_macro.py
index 339dada629..c93374dd21 100644
--- a/src/Mod/AddonManager/addonmanager_macro.py
+++ b/src/Mod/AddonManager/addonmanager_macro.py
@@ -34,7 +34,7 @@ import FreeCAD
import NetworkManager
from PySide import QtCore
-from addonmanager_utilities import remove_directory_if_empty, is_float
+from addonmanager_utilities import is_float
translate = FreeCAD.Qt.translate
@@ -509,89 +509,6 @@ class Macro:
).format(other_file, src_file)
)
- def remove(self) -> bool:
- """Remove a macro and all its related files
-
- Returns True if the macro was removed correctly.
- """
-
- if not self.is_installed():
- # Macro not installed, nothing to do.
- return True
- macro_dir = FreeCAD.getUserMacroDir(True)
-
- try:
- self._remove_core_macro_file(macro_dir)
- self._remove_xpm_data(macro_dir)
- self._remove_other_files(macro_dir)
- except IsADirectoryError:
- FreeCAD.Console.PrintError(
- translate(
- "AddonsInstaller",
- "Tried to remove a directory when a file was expected\n",
- )
- )
- return False
- except FileNotFoundError:
- FreeCAD.Console.PrintError(
- translate(
- "AddonsInstaller",
- "Macro file could not be found, nothing to remove\n",
- )
- )
- return False
- return True
-
- def _remove_other_files(self, macro_dir):
- # Remove related files, which are supposed to be given relative to
- # self.src_filename.
- for other_file in self.other_files:
- if not other_file:
- continue
- FreeCAD.Console.PrintMessage(other_file + "...")
- dst_file = os.path.join(macro_dir, other_file)
- if not dst_file or not os.path.exists(dst_file):
- FreeCAD.Console.PrintMessage("X\n")
- continue
- try:
- os.remove(dst_file)
- remove_directory_if_empty(os.path.dirname(dst_file))
- FreeCAD.Console.PrintMessage("✓\n")
- except IsADirectoryError:
- FreeCAD.Console.PrintMessage(" is a directory, not removed\n")
- except FileNotFoundError:
- FreeCAD.Console.PrintMessage(" could not be found, nothing to remove\n")
- if os.path.isabs(self.icon):
- dst_file = os.path.normpath(
- os.path.join(macro_dir, os.path.basename(self.icon))
- )
- if os.path.exists(dst_file):
- try:
- FreeCAD.Console.PrintMessage(os.path.basename(self.icon) + "...")
- os.remove(dst_file)
- FreeCAD.Console.PrintMessage("✓\n")
- except IsADirectoryError:
- FreeCAD.Console.PrintMessage(" is a directory, not removed\n")
- except FileNotFoundError:
- FreeCAD.Console.PrintMessage(
- " could not be found, nothing to remove\n"
- )
- return True
-
- def _remove_core_macro_file(self, macro_dir):
- macro_path = os.path.join(macro_dir, self.filename)
- macro_path_with_macro_prefix = os.path.join(macro_dir, "Macro_" + self.filename)
- if os.path.exists(macro_path):
- os.remove(macro_path)
- elif os.path.exists(macro_path_with_macro_prefix):
- os.remove(macro_path_with_macro_prefix)
-
- def _remove_xpm_data(self, macro_dir):
- if self.xpm:
- xpm_file = os.path.join(macro_dir, self.name + "_icon.xpm")
- if os.path.exists(xpm_file):
- os.remove(xpm_file)
-
def parse_wiki_page_for_icon(self, page_data: str) -> None:
"""Attempt to find a url for the icon in the wiki page. Sets self.icon if found."""
diff --git a/src/Mod/AddonManager/addonmanager_uninstaller.py b/src/Mod/AddonManager/addonmanager_uninstaller.py
new file mode 100644
index 0000000000..3e0b1d10ff
--- /dev/null
+++ b/src/Mod/AddonManager/addonmanager_uninstaller.py
@@ -0,0 +1,271 @@
+# ***************************************************************************
+# * *
+# * Copyright (c) 2022 FreeCAD Project Association *
+# * *
+# * This file is part of FreeCAD. *
+# * *
+# * FreeCAD is free software: you can redistribute it and/or modify it *
+# * under the terms of the GNU Lesser General Public License as *
+# * published by the Free Software Foundation, either version 2.1 of the *
+# * License, or (at your option) any later version. *
+# * *
+# * FreeCAD is distributed in the hope that it will be useful, but *
+# * WITHOUT ANY WARRANTY; without even the implied warranty of *
+# * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
+# * Lesser General Public License for more details. *
+# * *
+# * You should have received a copy of the GNU Lesser General Public *
+# * License along with FreeCAD. If not, see *
+# * . *
+# * *
+# ***************************************************************************
+
+""" Contains the classes to manage Addon removal: intended as a stable API, safe for external
+code to call and to rely upon existing. See classes AddonUninstaller and MacroUninstaller for
+details. """
+
+import os
+from typing import List
+
+import FreeCAD
+
+from PySide import QtCore
+
+import addonmanager_utilities as utils
+
+translate = FreeCAD.Qt.translate
+
+# pylint: disable=too-few-public-methods
+
+
+class InvalidAddon(RuntimeError):
+ """Raised when an object that cannot be uninstalled is passed to the constructor"""
+
+
+class AddonUninstaller(QtCore.QObject):
+ """The core, non-GUI uninstaller class for non-macro addons. Usually instantiated and moved to
+ its own thread, otherwise it will block the GUI (if the GUI is running) -- since all it does is
+ delete files this is not a huge problem, but in some cases the Addon might be quite large, and
+ deletion may take a non-trivial amount of time.
+
+ In all cases in this class, the generic Python 'object' argument to the init function is
+ intended to be an Addon-like object that provides, at a minimum, a 'name' attribute. The Addon
+ manager uses the Addon class for this purpose, but external code may use any other class that
+ meets that criterion.
+
+ Recommended Usage (when running with the GUI up, so you don't block the GUI thread):
+
+ addon_to_remove = MyAddon() # Some class with 'name' attribute
+
+ self.worker_thread = QtCore.QThread()
+ self.uninstaller = AddonInstaller(addon_to_remove)
+ self.uninstaller.moveToThread(self.worker_thread)
+ self.uninstaller.success.connect(self.removal_succeeded)
+ self.uninstaller.failure.connect(self.removal_failed)
+ self.uninstaller.finished.connect(self.worker_thread.quit)
+ self.worker_thread.started.connect(self.uninstaller.run)
+ self.worker_thread.start() # Returns immediately
+
+ # On success, the connections above result in self.removal_succeeded being emitted, and
+ # on failure, self.removal_failed is emitted.
+
+
+ Recommended non-GUI usage (blocks until complete):
+
+ addon_to_remove = MyAddon() # Some class with 'name' attribute
+ uninstaller = AddonInstaller(addon_to_remove)
+ uninstaller.run()
+
+ """
+
+ # Signals: success and failure
+ # Emitted when the installation process is complete. The object emitted is the object that the
+ # installation was requested for.
+ success = QtCore.Signal(object)
+ failure = QtCore.Signal(object, str)
+
+ # Finished: regardless of the outcome, this is emitted when all work that is going to be done
+ # is done (i.e. whatever thread this is running in can quit).
+ finished = QtCore.Signal()
+
+ def __init__(self, addon: object):
+ """Initialize the uninstaller."""
+ super().__init__()
+ self.addon_to_remove = addon
+ basedir = FreeCAD.getUserAppDataDir()
+ self.installation_path = os.path.join(basedir, "Mod")
+ self.macro_installation_path = FreeCAD.getUserMacroDir(True)
+
+ def run(self) -> bool:
+ """Remove an addon. Returns True if the addon was removed cleanly, or False if not. Emits
+ either success or failure prior to returning."""
+ success = False
+ error_message = translate("AddonsInstaller", "An unknown error occured")
+ if hasattr(self.addon_to_remove, "name") and self.addon_to_remove.name:
+ # Make sure we don't accidentally remove the Mod directory
+ path_to_remove = os.path.normpath(
+ os.path.join(self.installation_path, self.addon_to_remove.name)
+ )
+ if os.path.exists(path_to_remove) and not os.path.samefile(
+ path_to_remove, self.installation_path
+ ):
+ try:
+ self.run_uninstall_script(path_to_remove)
+ self.remove_extra_files(path_to_remove)
+ success = utils.rmdir(path_to_remove)
+ except OSError as e:
+ error_message = str(e)
+ else:
+ error_message = translate(
+ "AddonsInstaller",
+ "Could not find addon {} to remove it.",
+ ).format(self.addon_to_remove.name)
+ if success:
+ self.success.emit(self.addon_to_remove)
+ else:
+ self.failure.emit(self.addon_to_remove, error_message)
+ self.finished.emit()
+
+ def run_uninstall_script(self, path_to_remove):
+ """Run the addon's uninstaller.py script, if it exists"""
+ uninstall_script = os.path.join(path_to_remove, "uninstall.py")
+ if os.path.exists(uninstall_script):
+ print("Running script")
+ try:
+ with open(uninstall_script) as f:
+ exec(f.read())
+ print("I think I ran OK")
+ except Exception:
+ FreeCAD.Console.PrintError(
+ translate(
+ "AddonsInstaller",
+ "Execution of Addon's uninstall.py script failed. Proceeding with uninstall...",
+ )
+ + "\n"
+ )
+
+ def remove_extra_files(self, path_to_remove):
+ """When installing, an extra file called AM_INSTALLATION_DIGEST.txt may be created, listing
+ extra files that the installer put into place. Remove those files."""
+ digest = os.path.join(path_to_remove, "AM_INSTALLATION_DIGEST.txt")
+ if not os.path.exists(digest):
+ return
+ with open(digest, encoding="utf-8") as f:
+ lines = f.readlines()
+ for line in lines:
+ stripped = line.strip()
+ if (
+ len(stripped) > 0
+ and stripped[0] != "#"
+ and os.path.exists(stripped)
+ ):
+ try:
+ os.unlink(stripped)
+ FreeCAD.Console.PrintMessage(
+ translate(
+ "AddonsInstaller", "Removed extra installed file {}"
+ ).format(stripped)
+ + "\n"
+ )
+ except FileNotFoundError:
+ pass # Great, no need to remove then!
+ except OSError as e:
+ # Strange error to receive here, but just continue and print out an
+ # error to the console
+ FreeCAD.Console.PrintWarning(
+ translate(
+ "AddonsInstaller",
+ "Error while trying to remove extra installed file {}",
+ ).format(stripped)
+ + "\n"
+ )
+ FreeCAD.Console.PrintWarning(str(e) + "\n")
+
+
+class MacroUninstaller(QtCore.QObject):
+ """The core, non-GUI uninstaller class for macro addons. May be run directly on the GUI thread
+ if desired, since macros are intended to be relatively small and shouldn't have too many files
+ to delete. However, it is a QObject so may also be moved into a QThread -- see AddonUninstaller
+ documentation for details of that implementation.
+
+ The Python object passed in is expected to provide a "macro" subobject, which itself is
+ required to provide at least a "filename" attribute, and may also provide an "icon", "xpm",
+ and/or "other_files" attribute. All filenames provided by those attributes are expected to be
+ relative to the installed location of the "filename" macro file (usually the main FreeCAD
+ user macros directory)."""
+
+ # Signals: success and failure
+ # Emitted when the removal process is complete. The object emitted is the object that the
+ # removal was requested for.
+ success = QtCore.Signal(object)
+ failure = QtCore.Signal(object, str)
+
+ # Finished: regardless of the outcome, this is emitted when all work that is going to be done
+ # is done (i.e. whatever thread this is running in can quit).
+ finished = QtCore.Signal()
+
+ def __init__(self, addon):
+ super().__init__()
+ self.installation_location = FreeCAD.getUserMacroDir(True)
+ self.addon_to_remove = addon
+ if (
+ not hasattr(self.addon_to_remove, "macro")
+ or not self.addon_to_remove.macro
+ or not hasattr(self.addon_to_remove.macro, "filename")
+ or not self.addon_to_remove.macro.filename
+ ):
+ raise InvalidAddon()
+
+ def run(self):
+ """Execute the removal process."""
+ success = True
+ errors = []
+ directories = set()
+ for f in self._get_files_to_remove():
+ normed = os.path.normpath(f)
+ full_path = os.path.join(self.installation_location, normed)
+ if "/" in f:
+ directories.add(os.path.dirname(full_path))
+ try:
+ os.unlink(full_path)
+ FreeCAD.Console.PrintLog(f"Removed macro file {full_path}\n")
+ except FileNotFoundError:
+ pass # Great, no need to remove then!
+ except OSError as e:
+ # Probably permission denied, or something like that
+ errors.append(
+ translate(
+ "AddonsInstaller",
+ "Error while trying to remove macro file {}: ",
+ ).format(full_path)
+ + str(e)
+ )
+ success = False
+
+ self._cleanup_directories(directories)
+
+ if success:
+ self.success.emit(self.addon_to_remove)
+ else:
+ self.failure.emit(self.addon_to_remove, "\n".join(errors))
+ self.finished.emit()
+
+ def _get_files_to_remove(self) -> List[os.PathLike]:
+ """Get the list of files that should be removed"""
+ files_to_remove = []
+ files_to_remove.append(self.addon_to_remove.macro.filename)
+ if self.addon_to_remove.macro.icon:
+ files_to_remove.append(self.addon_to_remove.macro.icon)
+ if self.addon_to_remove.macro.xpm:
+ files_to_remove.append(
+ self.addon_to_remove.macro.name.replace(" ", "_") + "_icon.xpm"
+ )
+ for f in self.addon_to_remove.macro.other_files:
+ files_to_remove.append(f)
+ return files_to_remove
+
+ def _cleanup_directories(self, directories):
+ """Clean up any extra directories that are leftover and are empty"""
+ for directory in directories:
+ if os.path.isdir(directory):
+ utils.remove_directory_if_empty(directory)