Addon Manager: Create new uninstaller

This commit is contained in:
Chris Hennes
2022-12-18 13:56:36 -06:00
parent f9951c58d9
commit 2adbcc9199
8 changed files with 861 additions and 107 deletions

View File

@@ -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:

View File

@@ -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 *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
"""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, []

View File

@@ -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))
)

View File

@@ -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 *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
"""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))

View File

@@ -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

View File

@@ -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

View File

@@ -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."""

View File

@@ -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 *
# * <https://www.gnu.org/licenses/>. *
# * *
# ***************************************************************************
""" 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)