diff --git a/src/Mod/AddonManager/CMakeLists.txt b/src/Mod/AddonManager/CMakeLists.txt index 2d9b3e3376..7cad775fa4 100644 --- a/src/Mod/AddonManager/CMakeLists.txt +++ b/src/Mod/AddonManager/CMakeLists.txt @@ -26,6 +26,7 @@ SET(AddonManager_SRCS addonmanager_macro.py addonmanager_macro_parser.py addonmanager_metadata.py + addonmanager_pyside_interface.py addonmanager_update_all_gui.py addonmanager_uninstaller.py addonmanager_uninstaller_gui.py diff --git a/src/Mod/AddonManager/addonmanager_dependency_installer.py b/src/Mod/AddonManager/addonmanager_dependency_installer.py index 7ef1307c27..f066d527f4 100644 --- a/src/Mod/AddonManager/addonmanager_dependency_installer.py +++ b/src/Mod/AddonManager/addonmanager_dependency_installer.py @@ -28,8 +28,8 @@ import subprocess from typing import List import addonmanager_freecad_interface as fci +from addonmanager_pyside_interface import QObject, Signal, is_interruption_requested -from PySide import QtCore import addonmanager_utilities as utils from addonmanager_installer import AddonInstaller, MacroInstaller from Addon import Addon @@ -37,14 +37,14 @@ from Addon import Addon translate = fci.translate -class DependencyInstaller(QtCore.QObject): +class DependencyInstaller(QObject): """Install Python dependencies using pip. Intended to be instantiated and then moved into a QThread: connect the run() function to the QThread's started() signal.""" - no_python_exe = QtCore.Signal() - no_pip = QtCore.Signal(str) # Attempted command - failure = QtCore.Signal(str, str) # Short message, detailed message - finished = QtCore.Signal() + no_python_exe = Signal() + no_pip = Signal(str) # Attempted command + failure = Signal(str, str) # Short message, detailed message + finished = Signal() def __init__( self, @@ -69,9 +69,9 @@ class DependencyInstaller(QtCore.QObject): signal.""" if self._verify_pip(): if self.python_requires or self.python_optional: - if not QtCore.QThread.currentThread().isInterruptionRequested(): + if not is_interruption_requested(): self._install_python_packages() - if not QtCore.QThread.currentThread().isInterruptionRequested(): + if not is_interruption_requested(): self._install_addons() self.finished.emit() @@ -107,7 +107,7 @@ class DependencyInstaller(QtCore.QObject): signal is emitted and the function exits without proceeding with any additional installations.""" for pymod in self.python_requires: - if QtCore.QThread.currentThread().isInterruptionRequested(): + if is_interruption_requested(): return False try: proc = self._run_pip( @@ -136,7 +136,7 @@ class DependencyInstaller(QtCore.QObject): """Install the optional Python package dependencies. If any fail a message is printed to the console, but installation of the others continues.""" for pymod in self.python_optional: - if QtCore.QThread.currentThread().isInterruptionRequested(): + if is_interruption_requested(): return try: proc = self._run_pip( @@ -179,7 +179,7 @@ class DependencyInstaller(QtCore.QObject): def _install_addons(self): for addon in self.addons: - if QtCore.QThread.currentThread().isInterruptionRequested(): + if is_interruption_requested(): return fci.Console.PrintMessage( translate( diff --git a/src/Mod/AddonManager/addonmanager_git.py b/src/Mod/AddonManager/addonmanager_git.py index ab3e6a665e..bd89f9f292 100644 --- a/src/Mod/AddonManager/addonmanager_git.py +++ b/src/Mod/AddonManager/addonmanager_git.py @@ -31,11 +31,11 @@ import shutil import subprocess from typing import List, Optional import time -import FreeCAD import addonmanager_utilities as utils +import addonmanager_freecad_interface as fci -translate = FreeCAD.Qt.translate +translate = fci.translate class NoGitFound(RuntimeError): @@ -47,8 +47,9 @@ class GitFailed(RuntimeError): class GitManager: - """A class to manage access to git: mostly just provides a simple wrapper around the basic - command-line calls. Provides optional asynchronous access to clone and update.""" + """A class to manage access to git: mostly just provides a simple wrapper around + the basic command-line calls. Provides optional asynchronous access to clone and + update.""" def __init__(self): self.git_exe = None @@ -90,7 +91,7 @@ class GitManager: self._synchronous_call_git(["pull"]) self._synchronous_call_git(["submodule", "update", "--init", "--recursive"]) except GitFailed as e: - FreeCAD.Console.PrintWarning( + fci.Console.PrintWarning( translate( "AddonsInstaller", "Basic git update failed with the following message:", @@ -98,7 +99,7 @@ class GitManager: + str(e) + "\n" ) - FreeCAD.Console.PrintWarning( + fci.Console.PrintWarning( translate( "AddonsInstaller", "Backing up the original directory and re-cloning", @@ -110,9 +111,10 @@ class GitManager: os.path.join(local_path, "ADDON_DISABLED"), "w", encoding="utf-8" ) as f: f.write( - "This is a backup of an addon that failed to update cleanly so was re-cloned. " - + "It was disabled by the Addon Manager's git update facility and can be " - + "safely deleted if the addon is working properly." + "This is a backup of an addon that failed to update cleanly so " + "was re-cloned. It was disabled by the Addon Manager's git update " + "facility and can be safely deleted if the addon is working " + "properly." ) os.chdir("..") os.rename(local_path, local_path + ".backup" + str(time.time())) @@ -193,15 +195,16 @@ class GitManager: return branch def repair(self, remote, local_path): - """Assumes that local_path is supposed to be a local clone of the given remote, and - ensures that it is. Note that any local changes in local_path will be destroyed. This - is achieved by archiving the old path, cloning an entirely new copy, and then deleting - the old directory.""" + """Assumes that local_path is supposed to be a local clone of the given + remote, and ensures that it is. Note that any local changes in local_path + will be destroyed. This is achieved by archiving the old path, cloning an + entirely new copy, and then deleting the old directory.""" original_cwd = os.getcwd() - # Make sure we are not currently in that directory, otherwise on Windows the "rename" - # will fail. To guarantee we aren't in it, change to it, then shift up one. + # Make sure we are not currently in that directory, otherwise on Windows the + # "rename" will fail. To guarantee we aren't in it, change to it, then shift + # up one. os.chdir(local_path) os.chdir("..") backup_path = local_path + ".backup" + str(time.time()) @@ -209,7 +212,7 @@ class GitManager: try: self.clone(remote, local_path) except GitFailed as e: - FreeCAD.Console.PrintError( + fci.Console.PrintError( translate( "AddonsInstaller", "Failed to clone {} into {} using git" ).format(remote, local_path) @@ -232,7 +235,6 @@ class GitManager: result = "(unknown remote)" for line in lines: if line.endswith("(fetch)"): - # The line looks like: # origin https://some/sort/of/path (fetch) @@ -240,10 +242,10 @@ class GitManager: if len(segments) == 3: result = segments[1] break - FreeCAD.Console.PrintWarning( + fci.Console.PrintWarning( "Error parsing the results from git remote -v show:\n" ) - FreeCAD.Console.PrintWarning(line + "\n") + fci.Console.PrintWarning(line + "\n") os.chdir(old_dir) return result @@ -265,8 +267,10 @@ class GitManager: return branches def get_last_committers(self, local_path, n=10): - """Examine the last n entries of the commit history, and return a list of all the - committers, their email addresses, and how many commits each one is responsible for.""" + """Examine the last n entries of the commit history, and return a list of all + the committers, their email addresses, and how many commits each one is + responsible for. + """ old_dir = os.getcwd() os.chdir(local_path) authors = self._synchronous_call_git(["log", f"-{n}", "--format=%cN"]).split( @@ -294,8 +298,10 @@ class GitManager: return result_dict def get_last_authors(self, local_path, n=10): - """Examine the last n entries of the commit history, and return a list of all the - authors, their email addresses, and how many commits each one is responsible for.""" + """Examine the last n entries of the commit history, and return a list of all + the authors, their email addresses, and how many commits each one is + responsible for. + """ old_dir = os.getcwd() os.chdir(local_path) authors = self._synchronous_call_git(["log", f"-{n}", "--format=%aN"]) @@ -318,12 +324,12 @@ class GitManager: def _find_git(self): # Find git. In preference order # A) The value of the GitExecutable user preference - # B) The executable located in the same bin directory as FreeCAD and called "git" + # B) The executable located in the same directory as FreeCAD and called "git" # C) The result of a shutil search for your system's "git" executable - prefs = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") + prefs = fci.ParamGet("User parameter:BaseApp/Preferences/Addons") git_exe = prefs.GetString("GitExecutable", "Not set") if not git_exe or git_exe == "Not set" or not os.path.exists(git_exe): - fc_dir = FreeCAD.getHomePath() + fc_dir = fci.DataPaths().home_dir git_exe = os.path.join(fc_dir, "bin", "git") if "Windows" in platform.system(): git_exe += ".exe" @@ -349,7 +355,7 @@ class GitManager: f"Git returned a non-zero exit status: {e.returncode}\n" + f"Called with: {' '.join(final_args)}\n\n" + f"Returned stderr:\n{e.stderr}" - ) + ) from e return proc.stdout @@ -361,7 +367,7 @@ def initialize_git() -> Optional[GitManager]: preference group. Returns None if for any of those reasons we aren't using git.""" git_manager = None - pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") + pref = fci.ParamGet("User parameter:BaseApp/Preferences/Addons") disable_git = pref.GetBool("disableGit", False) if not disable_git: try: diff --git a/src/Mod/AddonManager/addonmanager_pyside_interface.py b/src/Mod/AddonManager/addonmanager_pyside_interface.py new file mode 100644 index 0000000000..74629e9c2e --- /dev/null +++ b/src/Mod/AddonManager/addonmanager_pyside_interface.py @@ -0,0 +1,60 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# *************************************************************************** +# * * +# * 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 * +# * . * +# * * +# *************************************************************************** + +"""Wrap QtCore imports so that can be replaced when running outside of FreeCAD (e.g. for +unit tests, etc.) Only provides wrappers for the things commonly used by the Addon +Manager.""" + +try: + from PySide import QtCore + + QObject = QtCore.QObject + Signal = QtCore.Signal + + def is_interruption_requested() -> bool: + return QtCore.QThread.currentThread().isInterruptionRequested() + +except ImportError: + QObject = object + + class Signal: + """A purely synchronous signal. emit() does not use queued slots so cannot be + used across threads.""" + + def __init__(self, *args): + self.expected_types = args + self.connections = [] + + def connect(self, func): + self.connections.append(func) + + def disconnect(self, func): + if func in self.connections: + self.connections.remove(func) + + def emit(self, *args): + for connection in self.connections: + connection(args) + + def is_interruption_requested() -> bool: + return False diff --git a/src/Mod/AddonManager/addonmanager_uninstaller.py b/src/Mod/AddonManager/addonmanager_uninstaller.py index 7bb1d051b2..87e9ac0812 100644 --- a/src/Mod/AddonManager/addonmanager_uninstaller.py +++ b/src/Mod/AddonManager/addonmanager_uninstaller.py @@ -21,21 +21,20 @@ # * * # *************************************************************************** -""" 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. """ +""" 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_freecad_interface as fci +from addonmanager_pyside_interface import QObject, Signal import addonmanager_utilities as utils from Addon import Addon -translate = FreeCAD.Qt.translate +translate = fci.translate # pylint: disable=too-few-public-methods @@ -44,22 +43,23 @@ 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. +class AddonUninstaller(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. + 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.worker_thread = QThread() self.uninstaller = AddonUninstaller(addon_to_remove) self.uninstaller.moveToThread(self.worker_thread) self.uninstaller.success.connect(self.removal_succeeded) @@ -68,8 +68,8 @@ class AddonUninstaller(QtCore.QObject): 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. + # 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): @@ -80,27 +80,25 @@ class AddonUninstaller(QtCore.QObject): """ - # 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) + # Signals: success and failure Emitted when the installation process is complete. + # The object emitted is the object that the installation was requested for. + success = Signal(object) + failure = 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() + # 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 = Signal() - def __init__(self, addon: object): + def __init__(self, addon: Addon): """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) + self.installation_path = fci.DataPaths().mod_dir + self.macro_installation_path = fci.DataPaths().macro_dir 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.""" + """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 occurred") if hasattr(self.addon_to_remove, "name") and self.addon_to_remove.name: @@ -128,18 +126,19 @@ class AddonUninstaller(QtCore.QObject): self.failure.emit(self.addon_to_remove, error_message) self.addon_to_remove.set_status(Addon.Status.NOT_INSTALLED) self.finished.emit() + return success - def run_uninstall_script(self, path_to_remove): + @staticmethod + def run_uninstall_script(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") + # pylint: disable=broad-exception-caught try: - with open(uninstall_script) as f: + with open(uninstall_script, encoding="utf-8") as f: exec(f.read()) - print("I think I ran OK") except Exception: - FreeCAD.Console.PrintError( + fci.Console.PrintError( translate( "AddonsInstaller", "Execution of Addon's uninstall.py script failed. Proceeding with uninstall...", @@ -147,9 +146,11 @@ class AddonUninstaller(QtCore.QObject): + "\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.""" + @staticmethod + def remove_extra_files(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 @@ -164,7 +165,7 @@ class AddonUninstaller(QtCore.QObject): ): try: os.unlink(stripped) - FreeCAD.Console.PrintMessage( + fci.Console.PrintMessage( translate( "AddonsInstaller", "Removed extra installed file {}" ).format(stripped) @@ -173,43 +174,43 @@ class AddonUninstaller(QtCore.QObject): 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( + # Strange error to receive here, but just continue and print + # out an error to the console + fci.Console.PrintWarning( translate( "AddonsInstaller", "Error while trying to remove extra installed file {}", ).format(stripped) + "\n" ) - FreeCAD.Console.PrintWarning(str(e) + "\n") + fci.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. +class MacroUninstaller(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).""" + 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) + # Signals: success and failure Emitted when the removal process is complete. The + # object emitted is the object that the removal was requested for. + success = Signal(object) + failure = 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() + # 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 = Signal() def __init__(self, addon): super().__init__() - self.installation_location = FreeCAD.getUserMacroDir(True) + self.installation_location = fci.DataPaths().macro_dir self.addon_to_remove = addon if ( not hasattr(self.addon_to_remove, "macro") @@ -231,7 +232,7 @@ class MacroUninstaller(QtCore.QObject): directories.add(os.path.dirname(full_path)) try: os.unlink(full_path) - FreeCAD.Console.PrintLog(f"Removed macro file {full_path}\n") + fci.Console.PrintLog(f"Removed macro file {full_path}\n") except FileNotFoundError: pass # Great, no need to remove then! except OSError as e: @@ -256,8 +257,7 @@ class MacroUninstaller(QtCore.QObject): 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) + files_to_remove = [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: @@ -268,7 +268,8 @@ class MacroUninstaller(QtCore.QObject): files_to_remove.append(f) return files_to_remove - def _cleanup_directories(self, directories): + @staticmethod + def _cleanup_directories(directories): """Clean up any extra directories that are leftover and are empty""" for directory in directories: if os.path.isdir(directory): diff --git a/src/Mod/AddonManager/package_details.py b/src/Mod/AddonManager/package_details.py index 5a7822994d..c31d03f54e 100644 --- a/src/Mod/AddonManager/package_details.py +++ b/src/Mod/AddonManager/package_details.py @@ -407,7 +407,7 @@ class PackageDetails(QtWidgets.QWidget): def set_change_branch_button_state(self): """The change branch button is only available for installed Addons that have a .git directory - and in runs where the GitPython import is available.""" + and in runs where the git is available.""" self.ui.buttonChangeBranch.hide()