Merge pull request #9068 from chennes/addonManagerExtractPysideInterface

Addon Manager: Extract PySide QtCore interface
This commit is contained in:
Chris Hennes
2023-03-26 20:45:25 -05:00
committed by GitHub
6 changed files with 177 additions and 109 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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