From 2adbcc91995c1facc3e7d6e37f056fbbe4f9a756 Mon Sep 17 00:00:00 2001 From: Chris Hennes Date: Sun, 18 Dec 2022 13:56:36 -0600 Subject: [PATCH] Addon Manager: Create new uninstaller --- src/Mod/AddonManager/AddonManager.py | 4 +- .../AddonManagerTest/app/mocks.py | 74 +++ .../AddonManagerTest/app/test_installer.py | 30 +- .../AddonManagerTest/app/test_uninstaller.py | 495 ++++++++++++++++++ src/Mod/AddonManager/CMakeLists.txt | 3 + src/Mod/AddonManager/TestAddonManagerApp.py | 6 + src/Mod/AddonManager/addonmanager_macro.py | 85 +-- .../AddonManager/addonmanager_uninstaller.py | 271 ++++++++++ 8 files changed, 861 insertions(+), 107 deletions(-) create mode 100644 src/Mod/AddonManager/AddonManagerTest/app/mocks.py create mode 100644 src/Mod/AddonManager/AddonManagerTest/app/test_uninstaller.py create mode 100644 src/Mod/AddonManager/addonmanager_uninstaller.py 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)