diff --git a/src/Mod/AddonManager/Addon.py b/src/Mod/AddonManager/Addon.py
index c660bcda1f..8862be9e30 100644
--- a/src/Mod/AddonManager/Addon.py
+++ b/src/Mod/AddonManager/Addon.py
@@ -24,12 +24,15 @@
import os
from urllib.parse import urlparse
-from typing import Dict, Set
+from typing import Dict, Set, List
from threading import Lock
from enum import IntEnum
import FreeCAD
+if FreeCAD.GuiUp:
+ import FreeCADGui
+
from addonmanager_macro import Macro
import addonmanager_utilities as utils
from addonmanager_utilities import construct_git_url
@@ -582,3 +585,80 @@ class Addon:
os.unlink(stopfile)
except FileNotFoundError:
pass
+
+
+# @dataclass(frozen)
+class MissingDependencies:
+ """Encapsulates a group of four types of dependencies:
+ * Internal workbenches -> wbs
+ * External addons -> external_addons
+ * Required Python packages -> python_requires
+ * Optional Python packages -> python_optional
+ """
+
+ def __init__(self, repo: Addon, all_repos: List[Addon]):
+
+ deps = Addon.Dependencies()
+ repo_name_dict = {}
+ for r in all_repos:
+ repo_name_dict[r.name] = r
+ if hasattr(r, "display_name"):
+ # Test harness might not provide a display name
+ repo_name_dict[r.display_name] = r
+
+ if hasattr(repo, "walk_dependency_tree"):
+ # Sometimes the test harness doesn't provide this function, to override any dependency
+ # checking
+ repo.walk_dependency_tree(repo_name_dict, deps)
+
+ self.external_addons = []
+ for dep in deps.required_external_addons:
+ if dep.status() == Addon.Status.NOT_INSTALLED:
+ self.external_addons.append(dep.name)
+
+ # Now check the loaded addons to see if we are missing an internal workbench:
+ if FreeCAD.GuiUp:
+ wbs = [wb.lower() for wb in FreeCADGui.listWorkbenches()]
+ else:
+ wbs = []
+
+ self.wbs = []
+ for dep in deps.internal_workbenches:
+ if dep.lower() + "workbench" not in wbs:
+ if dep.lower() == "plot":
+ # Special case for plot, which is no longer a full workbench:
+ try:
+ __import__("Plot")
+ except ImportError:
+ # Plot might fail for a number of reasons
+ self.wbs.append(dep)
+ FreeCAD.Console.PrintLog("Failed to import Plot module")
+ else:
+ self.wbs.append(dep)
+
+ # Check the Python dependencies:
+ self.python_min_version = deps.python_min_version
+ self.python_requires = []
+ for py_dep in deps.python_requires:
+ if py_dep not in self.python_requires:
+ try:
+ __import__(py_dep)
+ except ImportError:
+ self.python_requires.append(py_dep)
+
+ self.python_optional = []
+ for py_dep in deps.python_optional:
+ try:
+ __import__(py_dep)
+ except ImportError:
+ self.python_optional.append(py_dep)
+
+ self.wbs.sort()
+ self.external_addons.sort()
+ self.python_requires.sort()
+ self.python_optional.sort()
+ self.python_optional = [
+ option
+ for option in self.python_optional
+ if option not in self.python_requires
+ ]
diff --git a/src/Mod/AddonManager/AddonManager.py b/src/Mod/AddonManager/AddonManager.py
index 9d61f14982..49b98e3645 100644
--- a/src/Mod/AddonManager/AddonManager.py
+++ b/src/Mod/AddonManager/AddonManager.py
@@ -1,5 +1,4 @@
#!/usr/bin/env python
-# -*- coding: utf-8 -*-
# ***************************************************************************
# * *
@@ -26,16 +25,14 @@
import os
import functools
-import shutil
import stat
-import sys
import tempfile
import hashlib
import threading
import json
import re # Needed for py 3.6 and earlier, can remove later, search for "re."
from datetime import date, timedelta
-from typing import Dict, List
+from typing import Dict
from PySide import QtGui, QtCore, QtWidgets
import FreeCAD
@@ -49,20 +46,15 @@ from addonmanager_workers_startup import (
CacheMacroCodeWorker,
)
from addonmanager_workers_installation import (
- InstallWorkbenchWorker,
- DependencyInstallationWorker,
UpdateMetadataCacheWorker,
- UpdateAllWorker,
)
+from addonmanager_installer_gui import AddonInstallerGUI, MacroInstallerGUI
+from addonmanager_update_all_gui import UpdateAllGUI
import addonmanager_utilities as utils
import AddonManager_rc
from package_list import PackageList, PackageListItemModel
from package_details import PackageDetails
from Addon import Addon
-from install_to_toolbar import (
- ask_to_install_toolbar_button,
- remove_custom_toolbar_button,
-)
from manage_python_dependencies import (
PythonPackageManager,
)
@@ -96,8 +88,8 @@ Fetches various types of addons from a variety of sources. Built-in sources are:
Additional git sources may be configure via user preferences.
-You need a working internet connection, and optionally the GitPython package
-installed.
+You need a working internet connection, and optionally git -- if git is not available, ZIP archives
+are downloaded instead.
"""
# \defgroup ADDONMANAGER AddonManager
@@ -117,11 +109,9 @@ class CommandAddonManager:
"show_worker",
"showmacro_worker",
"macro_worker",
- "install_worker",
"update_metadata_cache_worker",
"load_macro_metadata_worker",
"update_all_worker",
- "dependency_installation_worker",
"check_for_python_package_updates_worker",
]
@@ -129,40 +119,16 @@ class CommandAddonManager:
restart_required = False
def __init__(self):
- # FreeCADGui.addPreferencePage(
- # os.path.join(os.path.dirname(__file__), "AddonManagerOptions.ui"),
- # translate("AddonsInstaller", "Addon Manager"),
- # )
FreeCADGui.addPreferencePage(
AddonManagerOptions,
translate("AddonsInstaller", "Addon Manager"),
)
- self.allowed_packages = set()
- allow_file = os.path.join(
- os.path.dirname(__file__), "ALLOWED_PYTHON_PACKAGES.txt"
- )
- if os.path.exists(allow_file):
- with open(allow_file, "r", encoding="utf8") as f:
- lines = f.readlines()
- for line in lines:
- if line and len(line) > 0 and line[0] != "#":
- self.allowed_packages.add(line.strip())
- else:
- FreeCAD.PrintWarning(
- translate(
- "AddonsInstaller",
- "Addon Manager installation problem: could not locate ALLOWED_PYTHON_PACKAGES.txt",
- )
- + "\n"
- )
-
- # Silence some pylint errors:
self.check_worker = None
self.check_for_python_package_updates_worker = None
- self.install_worker = None
self.update_all_worker = None
self.developer_mode = None
+ self.installer_gui = None
# Set up the connection checker
self.connection_checker = ConnectionCheckerGUI()
@@ -173,6 +139,7 @@ class CommandAddonManager:
INSTANCE = self
def GetResources(self) -> Dict[str, str]:
+ """FreeCAD-required function: get the core resource information for this Mod."""
return {
"Pixmap": "AddonManager",
"MenuText": QT_TRANSLATE_NOOP("Std_AddonMgr", "&Addon manager"),
@@ -184,13 +151,11 @@ class CommandAddonManager:
}
def Activated(self) -> None:
-
+ """FreeCAD-required function: called when the command is activated."""
NetworkManager.InitializeNetworkManager()
-
firstRunDialog = FirstRunDialog()
if not firstRunDialog.exec():
return
-
self.connection_checker.start()
def launch(self) -> None:
@@ -268,9 +233,6 @@ class CommandAddonManager:
else:
self.dialog.buttonDevTools.hide()
- # Only shown if there are available Python package updates
- # self.dialog.buttonUpdateDependencies.hide()
-
# connect slots
self.dialog.rejected.connect(self.reject)
self.dialog.buttonUpdateAll.clicked.connect(self.update_all)
@@ -287,7 +249,7 @@ class CommandAddonManager:
self.packageList.itemSelected.connect(self.table_row_activated)
self.packageList.setEnabled(False)
self.packageDetails.execute.connect(self.executemacro)
- self.packageDetails.install.connect(self.resolve_dependencies)
+ self.packageDetails.install.connect(self.launch_installer_gui)
self.packageDetails.uninstall.connect(self.remove)
self.packageDetails.update.connect(self.update)
self.packageDetails.back.connect(self.on_buttonBack_clicked)
@@ -314,7 +276,7 @@ class CommandAddonManager:
self.connection_check_message.close()
# rock 'n roll!!!
- self.dialog.exec_()
+ self.dialog.exec()
def cleanup_workers(self) -> None:
"""Ensure that no workers are running by explicitly asking them to stop and waiting for them until they do"""
@@ -528,9 +490,6 @@ class CommandAddonManager:
2, functools.partial(self.select_addon, selection)
)
pref.SetString("SelectedAddon", "")
- # TODO: migrate this to the developer mode tools
- # if ADDON_MANAGER_DEVELOPER_MODE:
- # self.startup_sequence.append(self.validate)
self.current_progress_region = 0
self.number_of_progress_regions = len(self.startup_sequence)
self.do_next_startup_phase()
@@ -681,10 +640,7 @@ class CommandAddonManager:
self.update_cache = True
cache_path = FreeCAD.getUserCachePath()
am_path = os.path.join(cache_path, "AddonManager")
- try:
- shutil.rmtree(am_path, onerror=self.remove_readonly)
- except Exception:
- pass
+ utils.rmdir(am_path)
self.dialog.buttonUpdateCache.setEnabled(False)
self.dialog.buttonUpdateCache.setText(
translate("AddonsInstaller", "Updating cache...")
@@ -809,7 +765,6 @@ class CommandAddonManager:
self.dialog.buttonCheckForUpdates.setEnabled(True)
def check_python_updates(self) -> None:
- self.update_allowed_packages_list() # Not really the best place for it...
PythonPackageManager.migrate_old_am_installations() # Migrate 0.20 to 0.21
self.do_next_startup_phase()
@@ -918,445 +873,10 @@ class CommandAddonManager:
def append_to_repos_list(self, repo: Addon) -> None:
"""this function allows threads to update the main list of workbenches"""
-
self.item_model.append_item(repo)
- # @dataclass(frozen)
- class MissingDependencies:
- """Encapsulates a group of four types of dependencies:
- * Internal workbenches -> wbs
- * External addons -> external_addons
- * Required Python packages -> python_requires
- * Optional Python packages -> python_optional
- """
-
- def __init__(self, repo: Addon, all_repos: List[Addon]):
-
- deps = Addon.Dependencies()
- repo_name_dict = {}
- for r in all_repos:
- repo_name_dict[r.name] = r
- repo_name_dict[r.display_name] = r
-
- repo.walk_dependency_tree(repo_name_dict, deps)
-
- self.external_addons = []
- for dep in deps.required_external_addons:
- if dep.status() == Addon.Status.NOT_INSTALLED:
- self.external_addons.append(dep.name)
-
- # Now check the loaded addons to see if we are missing an internal workbench:
- wbs = [wb.lower() for wb in FreeCADGui.listWorkbenches()]
-
- self.wbs = []
- for dep in deps.internal_workbenches:
- if dep.lower() + "workbench" not in wbs:
- if dep.lower() == "plot":
- # Special case for plot, which is no longer a full workbench:
- try:
- __import__("Plot")
- except ImportError:
- # Plot might fail for a number of reasons
- self.wbs.append(dep)
- FreeCAD.Console.PrintLog("Failed to import Plot module")
- else:
- self.wbs.append(dep)
-
- # Check the Python dependencies:
- self.python_min_version = deps.python_min_version
- self.python_requires = []
- for py_dep in deps.python_requires:
- if py_dep not in self.python_requires:
- try:
- __import__(py_dep)
- except ImportError:
- self.python_requires.append(py_dep)
-
- self.python_optional = []
- for py_dep in deps.python_optional:
- try:
- __import__(py_dep)
- except ImportError:
- self.python_optional.append(py_dep)
-
- self.wbs.sort()
- self.external_addons.sort()
- self.python_requires.sort()
- self.python_optional.sort()
- self.python_optional = [
- option
- for option in self.python_optional
- if option not in self.python_requires
- ]
-
- def update_allowed_packages_list(self) -> None:
- FreeCAD.Console.PrintLog(
- "Attempting to fetch remote copy of ALLOWED_PYTHON_PACKAGES.txt...\n"
- )
- p = NetworkManager.AM_NETWORK_MANAGER.blocking_get(
- "https://raw.githubusercontent.com/FreeCAD/FreeCAD-addons/master/ALLOWED_PYTHON_PACKAGES.txt"
- )
- if p:
- FreeCAD.Console.PrintLog(
- "Remote ALLOWED_PYTHON_PACKAGES.txt file located, overriding locally-installed copy\n"
- )
- p = p.data().decode("utf8")
- lines = p.split("\n")
- self.allowed_packages.clear() # Unset the locally-defined list
- for line in lines:
- if line and len(line) > 0 and line[0] != "#":
- self.allowed_packages.add(line.strip())
- else:
- FreeCAD.Console.PrintLog(
- "Could not fetch remote ALLOWED_PYTHON_PACKAGES.txt, using local copy\n"
- )
-
- def handle_disallowed_python(self, python_requires: List[str]) -> bool:
- """Determine if we are missing any required Python packages that are not in the allowed
- packages list. If so, display a message to the user, and return True. Otherwise return
- False."""
-
- bad_packages = []
- # self.update_allowed_packages_list()
- for dep in python_requires:
- if dep not in self.allowed_packages:
- bad_packages.append(dep)
-
- for dep in bad_packages:
- python_requires.remove(dep)
-
- if bad_packages:
- message = (
- "
"
- + translate(
- "AddonsInstaller",
- "This addon requires Python packages that are not installed, and cannot be installed automatically. To use this workbench you must install the following Python packages manually:",
- )
- + "
"
- )
- if len(bad_packages) < 15:
- for dep in bad_packages:
- message += f"- {dep}
"
- else:
- message += (
- "- ("
- + translate("AddonsInstaller", "Too many to list")
- + ")
"
- )
- message += "
"
- message += "To ignore this error and install anyway, press OK."
- r = QtWidgets.QMessageBox.critical(
- self.dialog,
- translate("AddonsInstaller", "Missing Requirement"),
- message,
- QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel,
- )
- FreeCAD.Console.PrintMessage(
- translate(
- "AddonsInstaller",
- "The following Python packages are allowed to be automatically installed",
- )
- + ":\n"
- )
- for package in self.allowed_packages:
- FreeCAD.Console.PrintMessage(f" * {package}\n")
-
- if r == QtWidgets.QMessageBox.Ok:
- # Force the installation to proceed
- return False
- else:
- return True
- else:
- return False
-
- def report_missing_workbenches(self, addon_name: str, wbs) -> bool:
- if len(wbs) == 1:
- name = wbs[0]
- message = translate(
- "AddonsInstaller",
- "Addon '{}' requires '{}', which is not available in your copy of FreeCAD.",
- ).format(addon_name, name)
- else:
- message = (
- ""
- + translate(
- "AddonsInstaller",
- "Addon '{}' requires the following workbenches, which are not available in your copy of FreeCAD:",
- ).format(addon_name)
- + "
"
- )
- for wb in wbs:
- message += "- " + wb + "
"
- message += "
"
- message += translate("AddonsInstaller", "Press OK to install anyway.")
- r = QtWidgets.QMessageBox.critical(
- self.dialog,
- translate("AddonsInstaller", "Missing Requirement"),
- message,
- QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel,
- )
- if r == QtWidgets.QMessageBox.Ok:
- return True
- else:
- return False
-
- def display_dep_resolution_dialog(self, missing, repo: Addon) -> None:
- self.dependency_dialog = FreeCADGui.PySideUic.loadUi(
- os.path.join(os.path.dirname(__file__), "dependency_resolution_dialog.ui")
- )
- self.dependency_dialog.setWindowFlag(QtCore.Qt.WindowStaysOnTopHint, True)
-
- for addon in missing.external_addons:
- self.dependency_dialog.listWidgetAddons.addItem(addon)
- for mod in missing.python_requires:
- self.dependency_dialog.listWidgetPythonRequired.addItem(mod)
- for mod in missing.python_optional:
- item = QtWidgets.QListWidgetItem(mod)
- item.setFlags(item.flags() | QtCore.Qt.ItemIsUserCheckable)
- item.setCheckState(QtCore.Qt.Unchecked)
- self.dependency_dialog.listWidgetPythonOptional.addItem(item)
-
- self.dependency_dialog.buttonBox.button(
- QtWidgets.QDialogButtonBox.Yes
- ).clicked.connect(functools.partial(self.dependency_dialog_yes_clicked, repo))
- self.dependency_dialog.buttonBox.button(
- QtWidgets.QDialogButtonBox.Ignore
- ).clicked.connect(
- functools.partial(self.dependency_dialog_ignore_clicked, repo)
- )
- self.dependency_dialog.buttonBox.button(
- QtWidgets.QDialogButtonBox.Cancel
- ).setDefault(True)
- self.dependency_dialog.exec()
-
- def resolve_dependencies(self, repo: Addon) -> None:
- if not repo:
- return
-
- missing = CommandAddonManager.MissingDependencies(repo, self.item_model.repos)
- if self.handle_disallowed_python(missing.python_requires):
- return
-
- # For now only look at the minor version, since major is always Python 3
- minor_required = missing.python_min_version["minor"]
- if sys.version_info.minor < minor_required:
- QtWidgets.QMessageBox.critical(
- self.dialog,
- translate("AddonsInstaller", "Incompatible Python version"),
- translate(
- "AddonsInstaller",
- "This Addon (or one if its dependencies) requires Python {}.{}, and your system is running {}.{}. Installation cancelled.",
- ).format(
- missing.python_min_version["major"],
- missing.python_min_version["minor"],
- sys.version_info.major,
- sys.version_info.minor,
- ),
- QtWidgets.QMessageBox.Cancel,
- )
- return
-
- good_packages = []
- for dep in missing.python_optional:
- if dep in self.allowed_packages:
- good_packages.append(dep)
- else:
- FreeCAD.Console.PrintWarning(
- translate(
- "AddonsInstaller",
- "Optional dependency on {} ignored because it is not in the allow-list\n",
- ).format(dep)
- )
- missing.python_optional = good_packages
-
- if missing.wbs:
- r = self.report_missing_workbenches(repo.display_name, missing.wbs)
- if r == False:
- return
- if (
- missing.external_addons
- or missing.python_requires
- or missing.python_optional
- ):
- # Recoverable: ask the user if they want to install the missing deps
- self.display_dep_resolution_dialog(missing, repo)
- else:
- # No missing deps, just install
- self.install(repo)
-
- def dependency_dialog_yes_clicked(self, installing_repo: Addon) -> None:
- # Get the lists out of the dialog:
- addons = []
- for row in range(self.dependency_dialog.listWidgetAddons.count()):
- item = self.dependency_dialog.listWidgetAddons.item(row)
- name = item.text()
- for repo in self.item_model.repos:
- if repo.name == name or repo.display_name == name:
- addons.append(repo)
-
- python_requires = []
- for row in range(self.dependency_dialog.listWidgetPythonRequired.count()):
- item = self.dependency_dialog.listWidgetPythonRequired.item(row)
- python_requires.append(item.text())
-
- python_optional = []
- for row in range(self.dependency_dialog.listWidgetPythonOptional.count()):
- item = self.dependency_dialog.listWidgetPythonOptional.item(row)
- if item.checkState() == QtCore.Qt.Checked:
- python_optional.append(item.text())
-
- self.dependency_installation_worker = DependencyInstallationWorker(
- addons, python_requires, python_optional
- )
- self.dependency_installation_worker.no_python_exe.connect(
- functools.partial(self.no_python_exe, installing_repo)
- )
- self.dependency_installation_worker.no_pip.connect(
- functools.partial(self.no_pip, repo=installing_repo)
- )
- self.dependency_installation_worker.failure.connect(
- self.dependency_installation_failure
- )
- self.dependency_installation_worker.success.connect(
- functools.partial(self.install, installing_repo)
- )
- self.dependency_installation_dialog = QtWidgets.QMessageBox(
- QtWidgets.QMessageBox.Information,
- translate("AddonsInstaller", "Installing dependencies"),
- translate("AddonsInstaller", "Installing dependencies") + "...",
- QtWidgets.QMessageBox.Cancel,
- self.dialog,
- )
- self.dependency_installation_dialog.rejected.connect(
- self.cancel_dependency_installation
- )
- self.dependency_installation_dialog.show()
- self.dependency_installation_worker.start()
-
- def no_python_exe(self, repo: Addon) -> None:
- if hasattr(self, "dependency_installation_dialog"):
- self.dependency_installation_dialog.hide()
- result = QtWidgets.QMessageBox.critical(
- self.dialog,
- translate("AddonsInstaller", "Cannot execute Python"),
- translate(
- "AddonsInstaller",
- "Failed to automatically locate your Python executable, or the path is set incorrectly. Please check the Addon Manager preferences setting for the path to Python.",
- )
- + "\n\n"
- + translate(
- "AddonsInstaller",
- "Dependencies could not be installed. Continue with installation of {} anyway?",
- ).format(repo.name),
- QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
- )
- if result == QtWidgets.QMessageBox.Yes:
- self.install(repo)
-
- def no_pip(self, command: str, repo: Addon) -> None:
- if hasattr(self, "dependency_installation_dialog"):
- self.dependency_installation_dialog.hide()
- result = QtWidgets.QMessageBox.critical(
- self.dialog,
- translate("AddonsInstaller", "Cannot execute pip"),
- translate(
- "AddonsInstaller",
- "Failed to execute pip, which may be missing from your Python installation. Please ensure your system has pip installed and try again. The failed command was: ",
- )
- + f"\n\n{command}\n\n"
- + translate(
- "AddonsInstaller",
- "Continue with installation of {} anyway?",
- ).format(repo.name),
- QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
- )
- if result == QtWidgets.QMessageBox.Yes:
- self.install(repo)
-
- def dependency_installation_failure(self, short_message: str, details: str) -> None:
- if hasattr(self, "dependency_installation_dialog"):
- self.dependency_installation_dialog.hide()
- FreeCAD.Console.PrintError(details)
- QtWidgets.QMessageBox.critical(
- self.dialog,
- translate("AddonsInstaller", "Package installation failed"),
- short_message
- + "\n\n"
- + translate("AddonsInstaller", "See Report View for detailed failure log."),
- QtWidgets.QMessageBox.Cancel,
- )
-
- def dependency_dialog_ignore_clicked(self, repo: Addon) -> None:
- self.install(repo)
-
- def cancel_dependency_installation(self) -> None:
- self.dependency_installation_worker.blockSignals(True)
- self.dependency_installation_worker.requestInterruption()
- self.dependency_installation_dialog.hide()
-
- def install(self, repo: Addon) -> None:
- """installs or updates a workbench, macro, or package"""
-
- if hasattr(self, "install_worker") and self.install_worker:
- if self.install_worker.isRunning():
- return
-
- if hasattr(self, "dependency_installation_dialog"):
- self.dependency_installation_dialog.hide()
-
- if not repo:
- return
-
- if (
- repo.repo_type == Addon.Kind.WORKBENCH
- or repo.repo_type == Addon.Kind.PACKAGE
- ):
- self.show_progress_widgets()
- self.install_worker = InstallWorkbenchWorker(repo)
- self.install_worker.status_message.connect(self.show_information)
- self.current_progress_region = 1
- self.number_of_progress_regions = 1
- self.install_worker.progress_made.connect(self.update_progress_bar)
- self.install_worker.success.connect(self.on_package_installed)
- self.install_worker.failure.connect(self.on_installation_failed)
- self.install_worker.start()
- elif repo.repo_type == Addon.Kind.MACRO:
- macro = repo.macro
-
- # To try to ensure atomicity, test the installation into a temp directory first,
- # and assume if that worked we have good odds of the real installation working
- failed = False
- errors = []
- with tempfile.TemporaryDirectory() as dir:
- temp_install_succeeded, error_list = macro.install(dir)
- if not temp_install_succeeded:
- failed = True
- errors = error_list
-
- if not failed:
- real_install_succeeded, errors = macro.install(self.macro_repo_dir)
- if not real_install_succeeded:
- failed = True
- else:
- utils.update_macro_installation_details(repo)
-
- if not failed:
- message = translate(
- "AddonsInstaller",
- "Macro successfully installed. The macro is now available from the Macros dialog.",
- )
- self.on_package_installed(repo, message)
- else:
- message = (
- translate("AddonsInstaller", "Installation of macro failed") + ":"
- )
- for error in errors:
- message += "\n * "
- message += error
- self.on_installation_failed(repo, message)
-
def update(self, repo: Addon) -> None:
- self.install(repo)
+ self.launch_installer_gui(repo)
def mark_repo_update_available(self, repo: Addon, available: bool) -> None:
if available:
@@ -1366,103 +886,46 @@ class CommandAddonManager:
self.item_model.reload_item(repo)
self.packageDetails.show_repo(repo)
- def update_all(self) -> None:
- """Asynchronously apply all available updates: individual failures are noted, but do not stop other updates"""
-
- if hasattr(self, "update_all_worker") and self.update_all_worker:
- if self.update_all_worker.isRunning():
- return
-
- self.subupdates_succeeded = []
- self.subupdates_failed = []
-
- self.show_progress_widgets()
- self.current_progress_region = 1
- self.number_of_progress_regions = 1
- self.update_all_worker = UpdateAllWorker(self.packages_with_updates)
- self.update_all_worker.progress_made.connect(self.update_progress_bar)
- self.update_all_worker.status_message.connect(self.show_information)
- self.update_all_worker.success.connect(self.subupdates_succeeded.append)
- self.update_all_worker.failure.connect(self.subupdates_failed.append)
- self.update_all_worker.finished.connect(self.on_update_all_completed)
- self.update_all_worker.start()
-
- def on_update_all_completed(self) -> None:
- self.hide_progress_widgets()
-
- def get_package_list(message: str, repos: List[Addon], threshold: int):
- """To ensure that the list doesn't get too long for the dialog, cut it off at some threshold"""
- num_updates = len(repos)
- if num_updates < threshold:
- result = "".join([repo.name + "\n" for repo in repos])
- else:
- result = translate(
- "AddonsInstaller",
- "{} total, see Report view for list",
- "Describes the number of updates that were completed ('{}' is replaced by the number of updates)",
- ).format(num_updates)
- for repo in repos:
- FreeCAD.Console.PrintMessage(f"{message}: {repo.name}\n")
- return result
-
- if not self.subupdates_failed:
- message = (
+ def launch_installer_gui(self, addon: Addon) -> None:
+ if self.installer_gui is not None:
+ FreeCAD.Console.PrintError(
translate(
"AddonsInstaller",
- "All packages were successfully updated",
+ "Cannot launch a new installer until the previous one has finished.",
)
- + ": \n"
- )
- message += get_package_list(
- translate("AddonsInstaller", "Succeeded"), self.subupdates_succeeded, 15
- )
- elif not self.subupdates_succeeded:
- message = (
- translate("AddonsInstaller", "All packages updates failed:") + "\n"
- )
- message += get_package_list(
- translate("AddonsInstaller", "Failed"), self.subupdates_failed, 15
)
+ return
+ if addon.macro is not None:
+ self.installer_gui = MacroInstallerGUI(addon)
else:
- message = (
+ self.installer_gui = AddonInstallerGUI(addon, self.item_model.repos)
+ self.installer_gui.success.connect(self.on_package_installed)
+ self.installer_gui.finished.connect(self.cleanup_installer)
+ self.installer_gui.run() # Does not block
+
+ def cleanup_installer(self) -> None:
+ QtCore.QTimer.singleShot(500, self.no_really_clean_up_the_installer)
+
+ def no_really_clean_up_the_installer(self) -> None:
+ self.installer_gui = None
+
+ def update_all(self) -> None:
+ """Asynchronously apply all available updates: individual failures are noted, but do not
+ stop other updates"""
+
+ if self.installer_gui is not None:
+ FreeCAD.Console.PrintError(
translate(
"AddonsInstaller",
- "Some packages updates failed.",
+ "Cannot launch a new installer until the previous one has finished.",
)
- + "\n\n"
- + translate(
- "AddonsInstaller",
- "Succeeded",
- )
- + ":\n"
- )
- message += get_package_list(
- translate("AddonsInstaller", "Succeeded"), self.subupdates_succeeded, 8
- )
- message += "\n\n"
- message += translate("AddonsInstaller", "Failed") + ":\n"
- message += get_package_list(
- translate("AddonsInstaller", "Failed"), self.subupdates_failed, 8
)
+ return
- for installed_repo in self.subupdates_succeeded:
- if installed_repo.contains_workbench():
- self.restart_required = True
- installed_repo.set_status(Addon.Status.PENDING_RESTART)
- else:
- installed_repo.set_status(Addon.Status.NO_UPDATE_AVAILABLE)
- self.item_model.reload_item(installed_repo)
- for requested_repo in self.packages_with_updates:
- if installed_repo.name == requested_repo.name:
- self.packages_with_updates.remove(installed_repo)
- break
- self.enable_updates(len(self.packages_with_updates))
- QtWidgets.QMessageBox.information(
- self.dialog,
- translate("AddonsInstaller", "Update report"),
- message,
- QtWidgets.QMessageBox.Close,
- )
+ self.installer_gui = UpdateAllGUI(self.item_model.repos)
+ self.installer_gui.addon_updated.connect(self.on_package_installed)
+ self.installer_gui.finished.connect(self.cleanup_installer)
+ self.installer_gui.run() # Does not block
def hide_progress_widgets(self) -> None:
"""hides the progress bar and related widgets"""
@@ -1516,14 +979,7 @@ class CommandAddonManager:
"AddonManager recaches."
)
- def on_package_installed(self, repo: Addon, message: str) -> None:
- self.hide_progress_widgets()
- QtWidgets.QMessageBox.information(
- self.dialog,
- translate("AddonsInstaller", "Installation succeeded"),
- message,
- QtWidgets.QMessageBox.Close,
- )
+ def on_package_installed(self, repo: Addon) -> None:
if repo.contains_workbench():
repo.set_status(Addon.Status.PENDING_RESTART)
self.restart_required = True
@@ -1531,21 +987,10 @@ class CommandAddonManager:
repo.set_status(Addon.Status.NO_UPDATE_AVAILABLE)
self.item_model.reload_item(repo)
self.packageDetails.show_repo(repo)
- if repo.repo_type == Addon.Kind.MACRO:
- ask_to_install_toolbar_button(repo)
if repo in self.packages_with_updates:
self.packages_with_updates.remove(repo)
self.enable_updates(len(self.packages_with_updates))
- def on_installation_failed(self, _: Addon, message: str) -> None:
- self.hide_progress_widgets()
- QtWidgets.QMessageBox.warning(
- self.dialog,
- translate("AddonsInstaller", "Installation failed"),
- message,
- QtWidgets.QMessageBox.Close,
- )
-
def executemacro(self, repo: Addon) -> None:
"""executes a selected macro"""
@@ -1574,12 +1019,6 @@ class CommandAddonManager:
self.dialog.hide()
FreeCADGui.SendMsgToActiveView("Run")
- def remove_readonly(self, func, path, _) -> None:
- """Remove a read-only file."""
-
- os.chmod(path, stat.S_IWRITE)
- func(path)
-
def remove(self, repo: Addon) -> None:
"""uninstalls a macro or workbench"""
@@ -1645,7 +1084,7 @@ class CommandAddonManager:
)
if os.path.exists(clonedir):
- shutil.rmtree(clonedir, onerror=self.remove_readonly)
+ utils.rmdir(clonedir)
self.item_model.update_item_status(
repo.name, Addon.Status.NOT_INSTALLED
)
@@ -1663,7 +1102,7 @@ class CommandAddonManager:
elif repo.repo_type == Addon.Kind.MACRO:
macro = repo.macro
if macro.remove():
- remove_custom_toolbar_button(repo)
+ # TODO: reimplement when refactored... remove_custom_toolbar_button(repo)
FreeCAD.Console.PrintMessage(
translate("AddonsInstaller", "Successfully uninstalled {}").format(
repo.name
@@ -1682,4 +1121,6 @@ class CommandAddonManager:
).format(repo.name)
+ "\n"
)
+
+
# @}
diff --git a/src/Mod/AddonManager/AddonManagerOptions.py b/src/Mod/AddonManager/AddonManagerOptions.py
index afa448bd31..443242fdb6 100644
--- a/src/Mod/AddonManager/AddonManagerOptions.py
+++ b/src/Mod/AddonManager/AddonManagerOptions.py
@@ -42,7 +42,8 @@ from PySide.QtWidgets import (
translate = FreeCAD.Qt.translate
-#pylint: disable=too-few-public-methods
+# pylint: disable=too-few-public-methods
+
class AddonManagerOptions:
"""A class containing a form element that is inserted as a FreeCAD preference page."""
diff --git a/src/Mod/AddonManager/AddonManagerTest/app/test_addon.py b/src/Mod/AddonManager/AddonManagerTest/app/test_addon.py
index 9131d19835..a2d0f5424e 100644
--- a/src/Mod/AddonManager/AddonManagerTest/app/test_addon.py
+++ b/src/Mod/AddonManager/AddonManagerTest/app/test_addon.py
@@ -29,17 +29,25 @@ import FreeCAD
from Addon import Addon, INTERNAL_WORKBENCHES
from addonmanager_macro import Macro
+
class TestAddon(unittest.TestCase):
MODULE = "test_addon" # file name without extension
def setUp(self):
- self.test_dir = os.path.join(FreeCAD.getHomePath(), "Mod", "AddonManager", "AddonManagerTest", "data")
+ self.test_dir = os.path.join(
+ FreeCAD.getHomePath(), "Mod", "AddonManager", "AddonManagerTest", "data"
+ )
def test_display_name(self):
# Case 1: No display name set elsewhere: name == display_name
- addon = Addon("FreeCAD","https://github.com/FreeCAD/FreeCAD", Addon.Status.NOT_INSTALLED, "master")
+ addon = Addon(
+ "FreeCAD",
+ "https://github.com/FreeCAD/FreeCAD",
+ Addon.Status.NOT_INSTALLED,
+ "master",
+ )
self.assertEqual(addon.name, "FreeCAD")
self.assertEqual(addon.display_name, "FreeCAD")
@@ -49,51 +57,68 @@ class TestAddon(unittest.TestCase):
self.assertEqual(addon.display_name, "Test Workbench")
def test_metadata_loading(self):
- addon = Addon("FreeCAD","https://github.com/FreeCAD/FreeCAD", Addon.Status.NOT_INSTALLED, "master")
+ addon = Addon(
+ "FreeCAD",
+ "https://github.com/FreeCAD/FreeCAD",
+ Addon.Status.NOT_INSTALLED,
+ "master",
+ )
addon.load_metadata_file(os.path.join(self.test_dir, "good_package.xml"))
# Generic tests:
self.assertIsNotNone(addon.metadata)
self.assertEqual(addon.metadata.Version, "1.0.1")
- self.assertEqual(addon.metadata.Description, "A package.xml file for unit testing.")
+ self.assertEqual(
+ addon.metadata.Description, "A package.xml file for unit testing."
+ )
maintainer_list = addon.metadata.Maintainer
- self.assertEqual(len(maintainer_list),1,"Wrong number of maintainers found")
- self.assertEqual(maintainer_list[0]["name"],"FreeCAD Developer")
- self.assertEqual(maintainer_list[0]["email"],"developer@freecad.org")
+ self.assertEqual(len(maintainer_list), 1, "Wrong number of maintainers found")
+ self.assertEqual(maintainer_list[0]["name"], "FreeCAD Developer")
+ self.assertEqual(maintainer_list[0]["email"], "developer@freecad.org")
license_list = addon.metadata.License
- self.assertEqual(len(license_list),1,"Wrong number of licenses found")
- self.assertEqual(license_list[0]["name"],"LGPLv2.1")
- self.assertEqual(license_list[0]["file"],"LICENSE")
+ self.assertEqual(len(license_list), 1, "Wrong number of licenses found")
+ self.assertEqual(license_list[0]["name"], "LGPLv2.1")
+ self.assertEqual(license_list[0]["file"], "LICENSE")
url_list = addon.metadata.Urls
- self.assertEqual(len(url_list),2,"Wrong number of urls found")
- self.assertEqual(url_list[0]["type"],"repository")
- self.assertEqual(url_list[0]["location"],"https://github.com/chennes/FreeCAD-Package")
- self.assertEqual(url_list[0]["branch"],"main")
- self.assertEqual(url_list[1]["type"],"readme")
- self.assertEqual(url_list[1]["location"],"https://github.com/chennes/FreeCAD-Package/blob/main/README.md")
+ self.assertEqual(len(url_list), 2, "Wrong number of urls found")
+ self.assertEqual(url_list[0]["type"], "repository")
+ self.assertEqual(
+ url_list[0]["location"], "https://github.com/chennes/FreeCAD-Package"
+ )
+ self.assertEqual(url_list[0]["branch"], "main")
+ self.assertEqual(url_list[1]["type"], "readme")
+ self.assertEqual(
+ url_list[1]["location"],
+ "https://github.com/chennes/FreeCAD-Package/blob/main/README.md",
+ )
contents = addon.metadata.Content
- self.assertEqual(len(contents),1,"Wrong number of content catetories found")
- self.assertEqual(len(contents["workbench"]),1,"Wrong number of workbenches found")
+ self.assertEqual(len(contents), 1, "Wrong number of content catetories found")
+ self.assertEqual(
+ len(contents["workbench"]), 1, "Wrong number of workbenches found"
+ )
def test_git_url_cleanup(self):
base_url = "https://github.com/FreeCAD/FreeCAD"
- test_urls = [f" {base_url} ",
- f"{base_url}.git",
- f" {base_url}.git "]
+ test_urls = [f" {base_url} ", f"{base_url}.git", f" {base_url}.git "]
for url in test_urls:
addon = Addon("FreeCAD", url, Addon.Status.NOT_INSTALLED, "master")
self.assertEqual(addon.url, base_url)
def test_tag_extraction(self):
- addon = Addon("FreeCAD","https://github.com/FreeCAD/FreeCAD", Addon.Status.NOT_INSTALLED, "master")
+ addon = Addon(
+ "FreeCAD",
+ "https://github.com/FreeCAD/FreeCAD",
+ Addon.Status.NOT_INSTALLED,
+ "master",
+ )
addon.load_metadata_file(os.path.join(self.test_dir, "good_package.xml"))
tags = addon.tags
- self.assertEqual(len(tags),5)
+ self.assertEqual(len(tags), 5)
expected_tags = set()
expected_tags.add("Tag0")
expected_tags.add("Tag1")
@@ -107,41 +132,79 @@ class TestAddon(unittest.TestCase):
# Test package.xml combinations:
# Workbenches
- addon_with_workbench = Addon("FreeCAD","https://github.com/FreeCAD/FreeCAD", Addon.Status.NOT_INSTALLED, "master")
- addon_with_workbench.load_metadata_file(os.path.join(self.test_dir, "workbench_only.xml"))
+ addon_with_workbench = Addon(
+ "FreeCAD",
+ "https://github.com/FreeCAD/FreeCAD",
+ Addon.Status.NOT_INSTALLED,
+ "master",
+ )
+ addon_with_workbench.load_metadata_file(
+ os.path.join(self.test_dir, "workbench_only.xml")
+ )
self.assertTrue(addon_with_workbench.contains_workbench())
self.assertFalse(addon_with_workbench.contains_macro())
self.assertFalse(addon_with_workbench.contains_preference_pack())
# Macros
- addon_with_macro = Addon("FreeCAD","https://github.com/FreeCAD/FreeCAD", Addon.Status.NOT_INSTALLED, "master")
- addon_with_macro.load_metadata_file(os.path.join(self.test_dir, "macro_only.xml"))
+ addon_with_macro = Addon(
+ "FreeCAD",
+ "https://github.com/FreeCAD/FreeCAD",
+ Addon.Status.NOT_INSTALLED,
+ "master",
+ )
+ addon_with_macro.load_metadata_file(
+ os.path.join(self.test_dir, "macro_only.xml")
+ )
self.assertFalse(addon_with_macro.contains_workbench())
self.assertTrue(addon_with_macro.contains_macro())
self.assertFalse(addon_with_macro.contains_preference_pack())
# Preference Packs
- addon_with_prefpack = Addon("FreeCAD","https://github.com/FreeCAD/FreeCAD", Addon.Status.NOT_INSTALLED, "master")
- addon_with_prefpack.load_metadata_file(os.path.join(self.test_dir, "prefpack_only.xml"))
+ addon_with_prefpack = Addon(
+ "FreeCAD",
+ "https://github.com/FreeCAD/FreeCAD",
+ Addon.Status.NOT_INSTALLED,
+ "master",
+ )
+ addon_with_prefpack.load_metadata_file(
+ os.path.join(self.test_dir, "prefpack_only.xml")
+ )
self.assertFalse(addon_with_prefpack.contains_workbench())
self.assertFalse(addon_with_prefpack.contains_macro())
self.assertTrue(addon_with_prefpack.contains_preference_pack())
# Combination
- addon_with_all = Addon("FreeCAD","https://github.com/FreeCAD/FreeCAD", Addon.Status.NOT_INSTALLED, "master")
- addon_with_all.load_metadata_file(os.path.join(self.test_dir, "combination.xml"))
+ addon_with_all = Addon(
+ "FreeCAD",
+ "https://github.com/FreeCAD/FreeCAD",
+ Addon.Status.NOT_INSTALLED,
+ "master",
+ )
+ addon_with_all.load_metadata_file(
+ os.path.join(self.test_dir, "combination.xml")
+ )
self.assertTrue(addon_with_all.contains_workbench())
self.assertTrue(addon_with_all.contains_macro())
self.assertTrue(addon_with_all.contains_preference_pack())
# Now do the simple, explicitly-set cases
- addon_wb = Addon("FreeCAD","https://github.com/FreeCAD/FreeCAD", Addon.Status.NOT_INSTALLED, "master")
+ addon_wb = Addon(
+ "FreeCAD",
+ "https://github.com/FreeCAD/FreeCAD",
+ Addon.Status.NOT_INSTALLED,
+ "master",
+ )
addon_wb.repo_type = Addon.Kind.WORKBENCH
self.assertTrue(addon_wb.contains_workbench())
self.assertFalse(addon_wb.contains_macro())
self.assertFalse(addon_wb.contains_preference_pack())
- addon_m = Addon("FreeCAD","https://github.com/FreeCAD/FreeCAD", Addon.Status.NOT_INSTALLED, "master")
+ addon_m = Addon(
+ "FreeCAD",
+ "https://github.com/FreeCAD/FreeCAD",
+ Addon.Status.NOT_INSTALLED,
+ "master",
+ )
addon_m.repo_type = Addon.Kind.MACRO
self.assertFalse(addon_m.contains_workbench())
self.assertTrue(addon_m.contains_macro())
@@ -157,18 +220,25 @@ class TestAddon(unittest.TestCase):
addon = Addon.from_macro(macro)
self.assertEqual(addon.repo_type, Addon.Kind.MACRO)
- self.assertEqual(addon.name,"DoNothing")
- self.assertEqual(addon.macro.comment,"Do absolutely nothing. For Addon Manager unit tests.")
- self.assertEqual(addon.url,"https://github.com/FreeCAD/FreeCAD")
- self.assertEqual(addon.macro.version,"1.0")
- self.assertEqual(len(addon.macro.other_files),3)
- self.assertEqual(addon.macro.author,"Chris Hennes")
- self.assertEqual(addon.macro.date,"2022-02-28")
- self.assertEqual(addon.macro.icon,"not_real.png")
- self.assertEqual(addon.macro.xpm,"")
+ self.assertEqual(addon.name, "DoNothing")
+ self.assertEqual(
+ addon.macro.comment, "Do absolutely nothing. For Addon Manager unit tests."
+ )
+ self.assertEqual(addon.url, "https://github.com/FreeCAD/FreeCAD")
+ self.assertEqual(addon.macro.version, "1.0")
+ self.assertEqual(len(addon.macro.other_files), 3)
+ self.assertEqual(addon.macro.author, "Chris Hennes")
+ self.assertEqual(addon.macro.date, "2022-02-28")
+ self.assertEqual(addon.macro.icon, "not_real.png")
+ self.assertEqual(addon.macro.xpm, "")
def test_cache(self):
- addon = Addon("FreeCAD","https://github.com/FreeCAD/FreeCAD", Addon.Status.NOT_INSTALLED, "master")
+ addon = Addon(
+ "FreeCAD",
+ "https://github.com/FreeCAD/FreeCAD",
+ Addon.Status.NOT_INSTALLED,
+ "master",
+ )
cache_data = addon.to_cache()
second_addon = Addon.from_cache(cache_data)
@@ -176,10 +246,30 @@ class TestAddon(unittest.TestCase):
def test_dependency_resolution(self):
- addonA = Addon("AddonA","https://github.com/FreeCAD/FakeAddonA", Addon.Status.NOT_INSTALLED, "master")
- addonB = Addon("AddonB","https://github.com/FreeCAD/FakeAddonB", Addon.Status.NOT_INSTALLED, "master")
- addonC = Addon("AddonC","https://github.com/FreeCAD/FakeAddonC", Addon.Status.NOT_INSTALLED, "master")
- addonD = Addon("AddonD","https://github.com/FreeCAD/FakeAddonD", Addon.Status.NOT_INSTALLED, "master")
+ addonA = Addon(
+ "AddonA",
+ "https://github.com/FreeCAD/FakeAddonA",
+ Addon.Status.NOT_INSTALLED,
+ "master",
+ )
+ addonB = Addon(
+ "AddonB",
+ "https://github.com/FreeCAD/FakeAddonB",
+ Addon.Status.NOT_INSTALLED,
+ "master",
+ )
+ addonC = Addon(
+ "AddonC",
+ "https://github.com/FreeCAD/FakeAddonC",
+ Addon.Status.NOT_INSTALLED,
+ "master",
+ )
+ addonD = Addon(
+ "AddonD",
+ "https://github.com/FreeCAD/FakeAddonD",
+ Addon.Status.NOT_INSTALLED,
+ "master",
+ )
addonA.requires.add("AddonB")
addonB.requires.add("AddonC")
@@ -191,30 +281,69 @@ class TestAddon(unittest.TestCase):
addonB.name: addonB,
addonC.name: addonC,
addonD.name: addonD,
- }
+ }
deps = Addon.Dependencies()
addonA.walk_dependency_tree(all_addons, deps)
- self.assertEqual(len(deps.required_external_addons),3)
+ self.assertEqual(len(deps.required_external_addons), 3)
addon_strings = [addon.name for addon in deps.required_external_addons]
- self.assertTrue("AddonB" in addon_strings, "AddonB not in required dependencies, and it should be.")
- self.assertTrue("AddonC" in addon_strings, "AddonC not in required dependencies, and it should be.")
- self.assertTrue("AddonD" in addon_strings, "AddonD not in required dependencies, and it should be.")
- self.assertTrue("Path" in deps.internal_workbenches, "Path not in workbench dependencies, and it should be.")
+ self.assertTrue(
+ "AddonB" in addon_strings,
+ "AddonB not in required dependencies, and it should be.",
+ )
+ self.assertTrue(
+ "AddonC" in addon_strings,
+ "AddonC not in required dependencies, and it should be.",
+ )
+ self.assertTrue(
+ "AddonD" in addon_strings,
+ "AddonD not in required dependencies, and it should be.",
+ )
+ self.assertTrue(
+ "Path" in deps.internal_workbenches,
+ "Path not in workbench dependencies, and it should be.",
+ )
def test_internal_workbench_list(self):
- addon = Addon("FreeCAD","https://github.com/FreeCAD/FreeCAD", Addon.Status.NOT_INSTALLED, "master")
- addon.load_metadata_file(os.path.join(self.test_dir, "depends_on_all_workbenches.xml"))
+ addon = Addon(
+ "FreeCAD",
+ "https://github.com/FreeCAD/FreeCAD",
+ Addon.Status.NOT_INSTALLED,
+ "master",
+ )
+ addon.load_metadata_file(
+ os.path.join(self.test_dir, "depends_on_all_workbenches.xml")
+ )
deps = Addon.Dependencies()
addon.walk_dependency_tree({}, deps)
self.assertEqual(len(deps.internal_workbenches), len(INTERNAL_WORKBENCHES))
def test_version_check(self):
- addon = Addon("FreeCAD","https://github.com/FreeCAD/FreeCAD", Addon.Status.NOT_INSTALLED, "master")
- addon.load_metadata_file(os.path.join(self.test_dir, "test_version_detection.xml"))
+ addon = Addon(
+ "FreeCAD",
+ "https://github.com/FreeCAD/FreeCAD",
+ Addon.Status.NOT_INSTALLED,
+ "master",
+ )
+ addon.load_metadata_file(
+ os.path.join(self.test_dir, "test_version_detection.xml")
+ )
- self.assertEqual(len(addon.tags),1, "Wrong number of tags found: version requirements should have restricted to only one")
- self.assertFalse("TagA" in addon.tags, "Found 'TagA' in tags, it should have been excluded by version requirement")
- self.assertTrue("TagB" in addon.tags, "Failed to find 'TagB' in tags, it should have been included")
- self.assertFalse("TagC" in addon.tags, "Found 'TagA' in tags, it should have been excluded by version requirement")
+ self.assertEqual(
+ len(addon.tags),
+ 1,
+ "Wrong number of tags found: version requirements should have restricted to only one",
+ )
+ self.assertFalse(
+ "TagA" in addon.tags,
+ "Found 'TagA' in tags, it should have been excluded by version requirement",
+ )
+ self.assertTrue(
+ "TagB" in addon.tags,
+ "Failed to find 'TagB' in tags, it should have been included",
+ )
+ self.assertFalse(
+ "TagC" in addon.tags,
+ "Found 'TagA' in tags, it should have been excluded by version requirement",
+ )
diff --git a/src/Mod/AddonManager/AddonManagerTest/app/test_dependency_installer.py b/src/Mod/AddonManager/AddonManagerTest/app/test_dependency_installer.py
new file mode 100644
index 0000000000..749e581bb3
--- /dev/null
+++ b/src/Mod/AddonManager/AddonManagerTest/app/test_dependency_installer.py
@@ -0,0 +1,208 @@
+# ***************************************************************************
+# * *
+# * 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 *
+# * . *
+# * *
+# ***************************************************************************
+
+import functools
+import os
+import subprocess
+import tempfile
+from time import sleep
+import unittest
+
+from addonmanager_dependency_installer import DependencyInstaller
+
+
+class CompleteProcessMock(subprocess.CompletedProcess):
+ def __init__(self):
+ super().__init__(["fake_arg"], 0)
+ self.stdout = "Mock suprocess call stdout result"
+
+
+class SubprocessMock:
+ def __init__(self):
+ self.arg_log = []
+ self.called = False
+ self.call_count = 0
+ self.delay = 0
+ self.succeed = True
+
+ def subprocess_interceptor(self, args):
+ self.arg_log.append(args)
+ self.called = True
+ self.call_count += 1
+ sleep(self.delay)
+ if self.succeed:
+ return CompleteProcessMock()
+ raise subprocess.CalledProcessError(1, " ".join(args), "Unit test mock output")
+
+
+class FakeFunction:
+ def __init__(self):
+ self.called = False
+ self.call_count = 0
+ self.return_value = None
+ self.arg_log = []
+
+ def func_call(self, *args):
+ self.arg_log.append(args)
+ self.called = True
+ self.call_count += 1
+ return self.return_value
+
+
+class TestDependencyInstaller(unittest.TestCase):
+ """Test the dependency installation class"""
+
+ def setUp(self):
+ self.subprocess_mock = SubprocessMock()
+ self.test_object = DependencyInstaller(
+ [], ["required_py_package"], ["optional_py_package"]
+ )
+ self.test_object._subprocess_wrapper = (
+ self.subprocess_mock.subprocess_interceptor
+ )
+ self.signals_caught = []
+ self.test_object.failure.connect(
+ functools.partial(self.catch_signal, "failure")
+ )
+ self.test_object.finished.connect(
+ functools.partial(self.catch_signal, "finished")
+ )
+ self.test_object.no_pip.connect(functools.partial(self.catch_signal, "no_pip"))
+ self.test_object.no_python_exe.connect(
+ functools.partial(self.catch_signal, "no_python_exe")
+ )
+
+ def tearDown(self):
+ pass
+
+ def catch_signal(self, signal_name, *_):
+ self.signals_caught.append(signal_name)
+
+ def test_run_no_pip(self):
+ self.test_object._verify_pip = lambda: False
+ self.test_object.run()
+ self.assertIn("finished", self.signals_caught)
+
+ def test_run_with_pip(self):
+ ff = FakeFunction()
+ self.test_object._verify_pip = lambda: True
+ self.test_object._install_python_packages = ff.func_call
+ self.test_object.run()
+ self.assertIn("finished", self.signals_caught)
+ self.assertTrue(ff.called)
+
+ def test_run_with_no_packages(self):
+ ff = FakeFunction()
+ self.test_object._verify_pip = lambda: True
+ self.test_object._install_python_packages = ff.func_call
+ self.test_object.python_requires = []
+ self.test_object.python_optional = []
+ self.test_object.run()
+ self.assertIn("finished", self.signals_caught)
+ self.assertFalse(ff.called)
+
+ def test_install_python_packages_new_location(self):
+ ff_required = FakeFunction()
+ ff_optional = FakeFunction()
+ self.test_object._install_required = ff_required.func_call
+ self.test_object._install_optional = ff_optional.func_call
+ with tempfile.TemporaryDirectory() as td:
+ self.test_object.location = os.path.join(td, "UnitTestLocation")
+ self.test_object._install_python_packages()
+ self.assertTrue(ff_required.called)
+ self.assertTrue(ff_optional.called)
+ self.assertTrue(os.path.exists(self.test_object.location))
+
+ def test_install_python_packages_existing_location(self):
+ ff_required = FakeFunction()
+ ff_optional = FakeFunction()
+ self.test_object._install_required = ff_required.func_call
+ self.test_object._install_optional = ff_optional.func_call
+ with tempfile.TemporaryDirectory() as td:
+ self.test_object.location = td
+ self.test_object._install_python_packages()
+ self.assertTrue(ff_required.called)
+ self.assertTrue(ff_optional.called)
+
+ def test_verify_pip_no_python(self):
+ self.test_object._get_python = lambda: None
+ should_continue = self.test_object._verify_pip()
+ self.assertFalse(should_continue)
+ self.assertEqual(len(self.signals_caught), 0)
+
+ def test_verify_pip_no_pip(self):
+ sm = SubprocessMock()
+ sm.succeed = False
+ self.test_object._subprocess_wrapper = sm.subprocess_interceptor
+ self.test_object._get_python = lambda: "fake_python"
+ result = self.test_object._verify_pip()
+ self.assertFalse(result)
+ self.assertIn("no_pip", self.signals_caught)
+
+ def test_verify_pip_with_pip(self):
+ sm = SubprocessMock()
+ sm.succeed = True
+ self.test_object._subprocess_wrapper = sm.subprocess_interceptor
+ self.test_object._get_python = lambda: "fake_python"
+ result = self.test_object._verify_pip()
+ self.assertTrue(result)
+ self.assertNotIn("no_pip", self.signals_caught)
+
+ def test_install_required_loops(self):
+ sm = SubprocessMock()
+ sm.succeed = True
+ self.test_object._subprocess_wrapper = sm.subprocess_interceptor
+ self.test_object._get_python = lambda: "fake_python"
+ self.test_object.python_requires = ["test1", "test2", "test3"]
+ self.test_object._install_required("vendor_path")
+ self.assertEqual(sm.call_count, 3)
+
+ def test_install_required_failure(self):
+ sm = SubprocessMock()
+ sm.succeed = False
+ self.test_object._subprocess_wrapper = sm.subprocess_interceptor
+ self.test_object._get_python = lambda: "fake_python"
+ self.test_object.python_requires = ["test1", "test2", "test3"]
+ self.test_object._install_required("vendor_path")
+ self.assertEqual(sm.call_count, 1)
+ self.assertIn("failure", self.signals_caught)
+
+ def test_install_optional_loops(self):
+ sm = SubprocessMock()
+ sm.succeed = True
+ self.test_object._subprocess_wrapper = sm.subprocess_interceptor
+ self.test_object._get_python = lambda: "fake_python"
+ self.test_object.python_optional = ["test1", "test2", "test3"]
+ self.test_object._install_optional("vendor_path")
+ self.assertEqual(sm.call_count, 3)
+
+ def test_install_optional_failure(self):
+ sm = SubprocessMock()
+ sm.succeed = False
+ self.test_object._subprocess_wrapper = sm.subprocess_interceptor
+ self.test_object._get_python = lambda: "fake_python"
+ self.test_object.python_optional = ["test1", "test2", "test3"]
+ self.test_object._install_optional("vendor_path")
+ self.assertEqual(sm.call_count, 3)
+
+ def test_run_pip(self):
+ pass
diff --git a/src/Mod/AddonManager/AddonManagerTest/app/test_git.py b/src/Mod/AddonManager/AddonManagerTest/app/test_git.py
index e70822e8d8..1009e3a5f6 100644
--- a/src/Mod/AddonManager/AddonManagerTest/app/test_git.py
+++ b/src/Mod/AddonManager/AddonManagerTest/app/test_git.py
@@ -69,7 +69,7 @@ class TestGit(unittest.TestCase):
def tearDown(self):
"""Clean up after the test"""
os.chdir(self.cwd)
- #self._rmdir(self.test_dir)
+ # self._rmdir(self.test_dir)
os.rename(self.test_dir, self.test_dir + ".old." + str(time.time()))
def test_clone(self):
@@ -77,7 +77,9 @@ class TestGit(unittest.TestCase):
checkout_dir = self._clone_test_repo()
self.assertTrue(os.path.exists(checkout_dir))
self.assertTrue(os.path.exists(os.path.join(checkout_dir, ".git")))
- self.assertEqual(os.getcwd(),self.cwd, "We should be left in the same CWD we started")
+ self.assertEqual(
+ os.getcwd(), self.cwd, "We should be left in the same CWD we started"
+ )
def test_checkout(self):
"""Test git checkout"""
@@ -88,7 +90,9 @@ class TestGit(unittest.TestCase):
expected_status = "## HEAD (no branch)"
self.assertEqual(status, expected_status)
- self.assertEqual(os.getcwd(),self.cwd, "We should be left in the same CWD we started")
+ self.assertEqual(
+ os.getcwd(), self.cwd, "We should be left in the same CWD we started"
+ )
def test_update(self):
"""Test using git to update the local repo"""
@@ -98,7 +102,9 @@ class TestGit(unittest.TestCase):
self.assertTrue(self.git.update_available(checkout_dir))
self.git.update(checkout_dir)
self.assertFalse(self.git.update_available(checkout_dir))
- self.assertEqual(os.getcwd(),self.cwd, "We should be left in the same CWD we started")
+ self.assertEqual(
+ os.getcwd(), self.cwd, "We should be left in the same CWD we started"
+ )
def test_tag_and_branch(self):
"""Test checking the currently checked-out tag"""
@@ -122,21 +128,25 @@ class TestGit(unittest.TestCase):
self.assertEqual(found_branch, expected_branch)
self.assertFalse(self.git.update_available(checkout_dir))
- self.assertEqual(os.getcwd(),self.cwd, "We should be left in the same CWD we started")
+ self.assertEqual(
+ os.getcwd(), self.cwd, "We should be left in the same CWD we started"
+ )
def test_get_remote(self):
- """ Test getting the remote location """
+ """Test getting the remote location"""
checkout_dir = self._clone_test_repo()
expected_remote = self.test_repo_remote
returned_remote = self.git.get_remote(checkout_dir)
self.assertEqual(expected_remote, returned_remote)
- self.assertEqual(os.getcwd(),self.cwd, "We should be left in the same CWD we started")
+ self.assertEqual(
+ os.getcwd(), self.cwd, "We should be left in the same CWD we started"
+ )
def test_repair(self):
- """ Test the repair feature (and some exception throwing) """
+ """Test the repair feature (and some exception throwing)"""
checkout_dir = self._clone_test_repo()
remote = self.git.get_remote(checkout_dir)
- git_dir = os.path.join(checkout_dir,".git")
+ git_dir = os.path.join(checkout_dir, ".git")
self.assertTrue(os.path.exists(git_dir))
self._rmdir(git_dir)
@@ -146,9 +156,10 @@ class TestGit(unittest.TestCase):
self.git.repair(remote, checkout_dir)
status = self.git.status(checkout_dir)
- self.assertEqual(status,"## main...origin/main\n")
- self.assertEqual(os.getcwd(),self.cwd, "We should be left in the same CWD we started")
-
+ self.assertEqual(status, "## main...origin/main\n")
+ self.assertEqual(
+ os.getcwd(), self.cwd, "We should be left in the same CWD we started"
+ )
def _rmdir(self, path):
try:
diff --git a/src/Mod/AddonManager/AddonManagerTest/app/test_installer.py b/src/Mod/AddonManager/AddonManagerTest/app/test_installer.py
new file mode 100644
index 0000000000..2adf53ba58
--- /dev/null
+++ b/src/Mod/AddonManager/AddonManagerTest/app/test_installer.py
@@ -0,0 +1,386 @@
+# ***************************************************************************
+# * *
+# * 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_installer.py non-GUI functionality."""
+
+import unittest
+import os
+import shutil
+import tempfile
+import time
+from zipfile import ZipFile
+import FreeCAD
+
+from typing import Dict
+
+from addonmanager_installer import InstallationMethod, AddonInstaller, MacroInstaller
+
+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"
+
+
+class TestAddonInstaller(unittest.TestCase):
+ """Test class for addonmanager_installer.py non-GUI functionality"""
+
+ MODULE = "test_installer" # file name without extension
+
+ def setUp(self):
+ """Initialize data needed for all tests"""
+ # self.start_time = time.perf_counter()
+ self.test_data_dir = os.path.join(
+ FreeCAD.getHomePath(), "Mod", "AddonManager", "AddonManagerTest", "data"
+ )
+ self.real_addon = Addon(
+ "TestAddon",
+ "https://github.com/FreeCAD/FreeCAD-addons",
+ Addon.Status.NOT_INSTALLED,
+ "master",
+ )
+ self.mock_addon = MockAddon()
+
+ def tearDown(self):
+ """Finalize the test."""
+ # end_time = time.perf_counter()
+ # print(f"Test '{self.id()}' ran in {end_time-self.start_time:.4f} seconds")
+
+ def test_validate_object(self):
+ """An object is valid if it has a name, url, and branch attribute."""
+
+ AddonInstaller._validate_object(self.real_addon) # Won't raise
+ AddonInstaller._validate_object(self.mock_addon) # Won't raise
+
+ class NoName:
+ def __init__(self):
+ self.url = "https://github.com/FreeCAD/FreeCAD-addons"
+ self.branch = "master"
+
+ no_name = NoName()
+ with self.assertRaises(RuntimeError):
+ AddonInstaller._validate_object(no_name)
+
+ class NoUrl:
+ def __init__(self):
+ self.name = "TestAddon"
+ self.branch = "master"
+
+ no_url = NoUrl()
+ with self.assertRaises(RuntimeError):
+ AddonInstaller._validate_object(no_url)
+
+ class NoBranch:
+ def __init__(self):
+ self.name = "TestAddon"
+ self.url = "https://github.com/FreeCAD/FreeCAD-addons"
+
+ no_branch = NoBranch()
+ with self.assertRaises(RuntimeError):
+ AddonInstaller._validate_object(no_branch)
+
+ def test_update_metadata(self):
+ """If a metadata file exists in the installation location, it should be loaded."""
+ installer = AddonInstaller(self.mock_addon, [], [])
+ with tempfile.TemporaryDirectory() as temp_dir:
+ installer.installation_path = temp_dir
+ addon_dir = os.path.join(temp_dir, self.mock_addon.name)
+ os.mkdir(addon_dir)
+ shutil.copy(
+ os.path.join(self.test_data_dir, "good_package.xml"),
+ os.path.join(addon_dir, "package.xml"),
+ )
+ installer._update_metadata() # Does nothing, but should not crash
+
+ installer = AddonInstaller(self.real_addon, [], [])
+ with tempfile.TemporaryDirectory() as temp_dir:
+ installer.installation_path = temp_dir
+ installer._update_metadata()
+ addon_dir = os.path.join(temp_dir, self.mock_addon.name)
+ os.mkdir(addon_dir)
+ shutil.copy(
+ os.path.join(self.test_data_dir, "good_package.xml"),
+ os.path.join(addon_dir, "package.xml"),
+ )
+ good_metadata = FreeCAD.Metadata(os.path.join(addon_dir, "package.xml"))
+ installer._update_metadata()
+ self.assertEqual(self.real_addon.installed_version, good_metadata.Version)
+
+ def test_finalize_zip_installation(self):
+ """Ensure that zipfiles are correctly extracted."""
+ with tempfile.TemporaryDirectory() as temp_dir:
+ test_simple_repo = os.path.join(self.test_data_dir, "test_simple_repo.zip")
+ non_gh_mock = MockAddon()
+ non_gh_mock.url = test_simple_repo
+ non_gh_mock.name = "NonGitHubMock"
+ installer = AddonInstaller(non_gh_mock, [], [])
+ installer.installation_path = temp_dir
+ installer._finalize_zip_installation(test_simple_repo)
+ expected_location = os.path.join(temp_dir, non_gh_mock.name, "README")
+ self.assertTrue(
+ os.path.isfile(expected_location), "Non-GitHub zip extraction failed"
+ )
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ test_github_style_repo = os.path.join(
+ self.test_data_dir, "test_github_style_repo.zip"
+ )
+ installer = AddonInstaller(self.mock_addon, [], [])
+ installer.installation_path = temp_dir
+ installer._finalize_zip_installation(test_github_style_repo)
+ expected_location = os.path.join(temp_dir, self.mock_addon.name, "README")
+ self.assertTrue(
+ os.path.isfile(expected_location), "GitHub zip extraction failed"
+ )
+
+ def test_install_by_git(self):
+ """Test using git to install. Depends on there being a local git installation: the test
+ is skipped if there is no local git."""
+ git_manager = initialize_git()
+ if not git_manager:
+ self.skipTest("git not found, skipping git installer tests")
+ return
+
+ # Our test git repo has to be in a zipfile, otherwise it cannot itself be stored in git,
+ # since it has a .git subdirectory.
+ with tempfile.TemporaryDirectory() as temp_dir:
+ git_repo_zip = os.path.join(self.test_data_dir, "test_repo.zip")
+ with ZipFile(git_repo_zip, "r") as zip_repo:
+ zip_repo.extractall(temp_dir)
+
+ mock_addon = MockAddon()
+ mock_addon.url = os.path.join(temp_dir, "test_repo")
+ mock_addon.branch = "main"
+ installer = AddonInstaller(mock_addon, [], [])
+ installer.installation_path = os.path.join(temp_dir, "installed_addon")
+ installer._install_by_git()
+
+ self.assertTrue(os.path.exists(installer.installation_path))
+ addon_name_dir = os.path.join(installer.installation_path, mock_addon.name)
+ self.assertTrue(os.path.exists(addon_name_dir))
+ readme = os.path.join(addon_name_dir, "README.md")
+ self.assertTrue(os.path.exists(readme))
+
+ def test_install_by_copy(self):
+ """Test using a simple filesystem copy to install an addon."""
+ with tempfile.TemporaryDirectory() as temp_dir:
+ git_repo_zip = os.path.join(self.test_data_dir, "test_repo.zip")
+ with ZipFile(git_repo_zip, "r") as zip_repo:
+ zip_repo.extractall(temp_dir)
+
+ mock_addon = MockAddon()
+ mock_addon.url = os.path.join(temp_dir, "test_repo")
+ mock_addon.branch = "main"
+ installer = AddonInstaller(mock_addon, [], [])
+ installer.addon_to_install = mock_addon
+ installer.installation_path = os.path.join(temp_dir, "installed_addon")
+ installer._install_by_copy()
+
+ self.assertTrue(os.path.exists(installer.installation_path))
+ addon_name_dir = os.path.join(installer.installation_path, mock_addon.name)
+ self.assertTrue(os.path.exists(addon_name_dir))
+ readme = os.path.join(addon_name_dir, "README.md")
+ self.assertTrue(os.path.exists(readme))
+
+ def test_determine_install_method_local_path(self):
+ """Test which install methods are accepted for a local path"""
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ installer = AddonInstaller(self.mock_addon, [], [])
+ method = installer._determine_install_method(
+ temp_dir, InstallationMethod.COPY
+ )
+ self.assertEqual(method, InstallationMethod.COPY)
+ git_manager = initialize_git()
+ if git_manager:
+ method = installer._determine_install_method(
+ temp_dir, InstallationMethod.GIT
+ )
+ self.assertEqual(method, InstallationMethod.GIT)
+ method = installer._determine_install_method(
+ temp_dir, InstallationMethod.ZIP
+ )
+ self.assertIsNone(method)
+ method = installer._determine_install_method(
+ temp_dir, InstallationMethod.ANY
+ )
+ self.assertEqual(method, InstallationMethod.COPY)
+
+ def test_determine_install_method_file_url(self):
+ """Test which install methods are accepted for a file:// url"""
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ installer = AddonInstaller(self.mock_addon, [], [])
+ temp_dir = "file://" + temp_dir.replace(os.path.sep, "/")
+ method = installer._determine_install_method(
+ temp_dir, InstallationMethod.COPY
+ )
+ self.assertEqual(method, InstallationMethod.COPY)
+ git_manager = initialize_git()
+ if git_manager:
+ method = installer._determine_install_method(
+ temp_dir, InstallationMethod.GIT
+ )
+ self.assertEqual(method, InstallationMethod.GIT)
+ method = installer._determine_install_method(
+ temp_dir, InstallationMethod.ZIP
+ )
+ self.assertIsNone(method)
+ method = installer._determine_install_method(
+ temp_dir, InstallationMethod.ANY
+ )
+ self.assertEqual(method, InstallationMethod.COPY)
+
+ def test_determine_install_method_local_zip(self):
+ """Test which install methods are accepted for a local path to a zipfile"""
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ installer = AddonInstaller(self.mock_addon, [], [])
+ temp_file = os.path.join(temp_dir, "dummy.zip")
+ method = installer._determine_install_method(
+ temp_file, InstallationMethod.COPY
+ )
+ self.assertEqual(method, InstallationMethod.ZIP)
+ method = installer._determine_install_method(
+ temp_file, InstallationMethod.GIT
+ )
+ self.assertIsNone(method)
+ method = installer._determine_install_method(
+ temp_file, InstallationMethod.ZIP
+ )
+ self.assertEqual(method, InstallationMethod.ZIP)
+ method = installer._determine_install_method(
+ temp_file, InstallationMethod.ANY
+ )
+ self.assertEqual(method, InstallationMethod.ZIP)
+
+ def test_determine_install_method_remote_zip(self):
+ """Test which install methods are accepted for a remote path to a zipfile"""
+
+ installer = AddonInstaller(self.mock_addon, [], [])
+
+ temp_file = "https://freecad.org/dummy.zip" # Doesn't have to actually exist!
+
+ method = installer._determine_install_method(temp_file, InstallationMethod.COPY)
+ self.assertIsNone(method)
+ method = installer._determine_install_method(temp_file, InstallationMethod.GIT)
+ self.assertIsNone(method)
+ method = installer._determine_install_method(temp_file, InstallationMethod.ZIP)
+ self.assertEqual(method, InstallationMethod.ZIP)
+ method = installer._determine_install_method(temp_file, InstallationMethod.ANY)
+ self.assertEqual(method, InstallationMethod.ZIP)
+
+ def test_determine_install_method_https_known_sites(self):
+ """Test which install methods are accepted for an https github URL"""
+
+ installer = AddonInstaller(self.mock_addon, [], [])
+
+ for site in ["github.org", "gitlab.org", "framagit.org", "salsa.debian.org"]:
+ temp_file = f"https://{site}/dummy/dummy" # Doesn't have to actually exist!
+ method = installer._determine_install_method(
+ temp_file, InstallationMethod.COPY
+ )
+ self.assertIsNone(method, f"Allowed copying from {site} URL")
+ method = installer._determine_install_method(
+ temp_file, InstallationMethod.GIT
+ )
+ self.assertEqual(
+ method,
+ InstallationMethod.GIT,
+ f"Failed to allow git access to {site} URL",
+ )
+ method = installer._determine_install_method(
+ temp_file, InstallationMethod.ZIP
+ )
+ self.assertEqual(
+ method,
+ InstallationMethod.ZIP,
+ f"Failed to allow zip access to {site} URL",
+ )
+ method = installer._determine_install_method(
+ temp_file, InstallationMethod.ANY
+ )
+ git_manager = initialize_git()
+ if git_manager:
+ self.assertEqual(
+ method,
+ InstallationMethod.GIT,
+ f"Failed to allow git access to {site} URL",
+ )
+ else:
+ self.assertEqual(
+ method,
+ InstallationMethod.ZIP,
+ f"Failed to allow zip access to {site} URL",
+ )
+
+ def test_fcmacro_copying(self):
+ with tempfile.TemporaryDirectory() as temp_dir:
+ mock_addon = MockAddon()
+ mock_addon.url = os.path.join(
+ self.test_data_dir, "test_addon_with_fcmacro.zip"
+ )
+ installer = AddonInstaller(mock_addon, [], [])
+ installer.installation_path = temp_dir
+ installer.macro_installation_path = os.path.join(temp_dir, "Macros")
+ installer.run()
+ self.assertTrue(
+ os.path.exists(os.path.join(temp_dir, "Macros", "TestMacro.FCMacro")),
+ "FCMacro file was not copied to macro installation location",
+ )
+
+
+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, []
+
+ class AddonMock:
+ def __init__(self):
+ self.macro = MacroMock()
+
+ self.mock = AddonMock()
+
+ def test_installation(self):
+ 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"))
+ )
diff --git a/src/Mod/AddonManager/AddonManagerTest/app/test_macro.py b/src/Mod/AddonManager/AddonManagerTest/app/test_macro.py
index 769e3d562d..21fdf2aab3 100644
--- a/src/Mod/AddonManager/AddonManagerTest/app/test_macro.py
+++ b/src/Mod/AddonManager/AddonManagerTest/app/test_macro.py
@@ -31,12 +31,15 @@ from typing import Dict
from addonmanager_macro import Macro
+
class TestMacro(unittest.TestCase):
MODULE = "test_macro" # file name without extension
def setUp(self):
- self.test_dir = os.path.join(FreeCAD.getHomePath(), "Mod", "AddonManager", "AddonManagerTest", "data")
+ self.test_dir = os.path.join(
+ FreeCAD.getHomePath(), "Mod", "AddonManager", "AddonManagerTest", "data"
+ )
def test_basic_metadata(self):
replacements = {
@@ -100,7 +103,7 @@ class TestMacro(unittest.TestCase):
if "VERSION" in line:
line = "__Version__ = __Date__"
output_lines.append(line)
- with open(outfile,"w") as f:
+ with open(outfile, "w") as f:
f.write("\n".join(output_lines))
m = Macro("Unit Test Macro")
m.fill_details_from_file(outfile)
@@ -115,7 +118,7 @@ class TestMacro(unittest.TestCase):
if "VERSION" in line:
line = "__Version__ = 1.23"
output_lines.append(line)
- with open(outfile,"w") as f:
+ with open(outfile, "w") as f:
f.write("\n".join(output_lines))
m = Macro("Unit Test Macro")
m.fill_details_from_file(outfile)
@@ -130,7 +133,7 @@ class TestMacro(unittest.TestCase):
if "VERSION" in line:
line = "__Version__ = 1"
output_lines.append(line)
- with open(outfile,"w") as f:
+ with open(outfile, "w") as f:
f.write("\n".join(output_lines))
m = Macro("Unit Test Macro")
m.fill_details_from_file(outfile)
@@ -153,28 +156,27 @@ static char * blarg_xpm[] = {
};"""
with open(outfile) as f:
contents = f.read()
- contents += f"\n__xpm__ = \"\"\"{xpm_data}\"\"\"\n"
+ contents += f'\n__xpm__ = """{xpm_data}"""\n'
- with open(outfile,"w") as f:
+ with open(outfile, "w") as f:
f.write(contents)
m = Macro("Unit Test Macro")
m.fill_details_from_file(outfile)
self.assertEqual(m.xpm, xpm_data)
-
- def generate_macro_file(self, replacements:Dict[str,str] = {}) -> os.PathLike:
- with open(os.path.join(self.test_dir,"macro_template.FCStd")) as f:
+ def generate_macro_file(self, replacements: Dict[str, str] = {}) -> os.PathLike:
+ with open(os.path.join(self.test_dir, "macro_template.FCStd")) as f:
lines = f.readlines()
- outfile = tempfile.NamedTemporaryFile(mode="wt",delete=False)
+ outfile = tempfile.NamedTemporaryFile(mode="wt", delete=False)
for line in lines:
- for key,value in replacements.items():
- line = line.replace(key,value)
+ for key, value in replacements.items():
+ line = line.replace(key, value)
outfile.write(line)
outfile.close()
return outfile.name
- def generate_macro(self, replacements:Dict[str,str] = {}) -> Macro:
+ def generate_macro(self, replacements: Dict[str, str] = {}) -> Macro:
outfile = self.generate_macro_file(replacements)
m = Macro("Unit Test Macro")
m.fill_details_from_file(outfile)
diff --git a/src/Mod/AddonManager/AddonManagerTest/app/test_utilities.py b/src/Mod/AddonManager/AddonManagerTest/app/test_utilities.py
index 57734e32fa..af83269b64 100644
--- a/src/Mod/AddonManager/AddonManagerTest/app/test_utilities.py
+++ b/src/Mod/AddonManager/AddonManagerTest/app/test_utilities.py
@@ -41,7 +41,9 @@ class TestUtilities(unittest.TestCase):
MODULE = "test_utilities" # file name without extension
def setUp(self):
- self.test_dir = os.path.join(FreeCAD.getHomePath(), "Mod", "AddonManager", "AddonManagerTest", "data")
+ self.test_dir = os.path.join(
+ FreeCAD.getHomePath(), "Mod", "AddonManager", "AddonManagerTest", "data"
+ )
def test_recognized_git_location(self):
recognized_urls = [
@@ -51,9 +53,7 @@ class TestUtilities(unittest.TestCase):
"https://salsa.debian.org/science-team/freecad",
]
for url in recognized_urls:
- repo = Addon(
- "Test Repo", url, Addon.Status.NOT_INSTALLED, "branch"
- )
+ repo = Addon("Test Repo", url, Addon.Status.NOT_INSTALLED, "branch")
self.assertTrue(
recognized_git_location(repo), f"{url} was unexpectedly not recognized"
)
@@ -65,9 +65,7 @@ class TestUtilities(unittest.TestCase):
"https://github.com.malware.com/",
]
for url in unrecognized_urls:
- repo = Addon(
- "Test Repo", url, Addon.Status.NOT_INSTALLED, "branch"
- )
+ repo = Addon("Test Repo", url, Addon.Status.NOT_INSTALLED, "branch")
self.assertFalse(
recognized_git_location(repo), f"{url} was unexpectedly recognized"
)
@@ -90,18 +88,14 @@ class TestUtilities(unittest.TestCase):
for url in github_urls:
branch = "branchname"
expected_result = f"{url}/raw/{branch}/README.md"
- repo = Addon(
- "Test Repo", url, Addon.Status.NOT_INSTALLED, branch
- )
+ repo = Addon("Test Repo", url, Addon.Status.NOT_INSTALLED, branch)
actual_result = get_readme_url(repo)
self.assertEqual(actual_result, expected_result)
for url in gitlab_urls:
branch = "branchname"
expected_result = f"{url}/-/raw/{branch}/README.md"
- repo = Addon(
- "Test Repo", url, Addon.Status.NOT_INSTALLED, branch
- )
+ repo = Addon("Test Repo", url, Addon.Status.NOT_INSTALLED, branch)
actual_result = get_readme_url(repo)
self.assertEqual(actual_result, expected_result)
@@ -139,4 +133,4 @@ class TestUtilities(unittest.TestCase):
empty_file = os.path.join(self.test_dir, "missing_macro_metadata.FCStd")
version = get_macro_version_from_file(empty_file)
- self.assertEqual(version, "", "Missing version did not yield empty string")
\ No newline at end of file
+ self.assertEqual(version, "", "Missing version did not yield empty string")
diff --git a/src/Mod/AddonManager/AddonManagerTest/data/test_addon_with_fcmacro.zip b/src/Mod/AddonManager/AddonManagerTest/data/test_addon_with_fcmacro.zip
new file mode 100644
index 0000000000..46dce11c5b
Binary files /dev/null and b/src/Mod/AddonManager/AddonManagerTest/data/test_addon_with_fcmacro.zip differ
diff --git a/src/Mod/AddonManager/AddonManagerTest/data/test_github_style_repo.zip b/src/Mod/AddonManager/AddonManagerTest/data/test_github_style_repo.zip
new file mode 100644
index 0000000000..f9124296fc
Binary files /dev/null and b/src/Mod/AddonManager/AddonManagerTest/data/test_github_style_repo.zip differ
diff --git a/src/Mod/AddonManager/AddonManagerTest/data/test_simple_repo.zip b/src/Mod/AddonManager/AddonManagerTest/data/test_simple_repo.zip
new file mode 100644
index 0000000000..9ce0ee9ac3
Binary files /dev/null and b/src/Mod/AddonManager/AddonManagerTest/data/test_simple_repo.zip differ
diff --git a/src/Mod/AddonManager/AddonManagerTest/gui/gui_mocks.py b/src/Mod/AddonManager/AddonManagerTest/gui/gui_mocks.py
new file mode 100644
index 0000000000..7a1d4979ae
--- /dev/null
+++ b/src/Mod/AddonManager/AddonManagerTest/gui/gui_mocks.py
@@ -0,0 +1,22 @@
+# ***************************************************************************
+# * *
+# * 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 *
+# * . *
+# * *
+# ***************************************************************************
+
diff --git a/src/Mod/AddonManager/AddonManagerTest/gui/test_installer_gui.py b/src/Mod/AddonManager/AddonManagerTest/gui/test_installer_gui.py
new file mode 100644
index 0000000000..38394db6c1
--- /dev/null
+++ b/src/Mod/AddonManager/AddonManagerTest/gui/test_installer_gui.py
@@ -0,0 +1,750 @@
+# ***************************************************************************
+# * *
+# * 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 *
+# * . *
+# * *
+# ***************************************************************************
+import os
+import tempfile
+import unittest
+import FreeCAD
+
+from PySide import QtCore, QtWidgets
+
+from addonmanager_installer_gui import AddonInstallerGUI, MacroInstallerGUI
+
+translate = FreeCAD.Qt.translate
+
+
+class DialogWatcher(QtCore.QObject):
+ def __init__(self, dialog_to_watch_for, button):
+ super().__init__()
+ self.dialog_found = False
+ self.has_run = False
+ self.dialog_to_watch_for = dialog_to_watch_for
+ self.button = button
+
+ def run(self):
+ widget = QtWidgets.QApplication.activeModalWidget()
+ if widget:
+ # Is this the widget we are looking for?
+ if (
+ hasattr(widget, "windowTitle")
+ and callable(widget.windowTitle)
+ and widget.windowTitle() == self.dialog_to_watch_for
+ ):
+ # Found the dialog we are looking for: now try to "click" the appropriate button
+ self.click_button(widget)
+ self.dialog_found = True
+ self.has_run = True
+
+ def click_button(self, widget):
+ buttons = widget.findChildren(QtWidgets.QPushButton)
+ for button in buttons:
+ text = button.text().replace("&", "")
+ if text == self.button:
+ button.click()
+
+
+class DialogInteractor(DialogWatcher):
+ def __init__(self, dialog_to_watch_for, interaction):
+ """Takes the title of the dialog, a button string, and a callable."""
+ super().__init__(dialog_to_watch_for, None)
+ self.interaction = interaction
+
+ def run(self):
+ widget = QtWidgets.QApplication.activeModalWidget()
+ if widget:
+ # Is this the widget we are looking for?
+ if (
+ hasattr(widget, "windowTitle")
+ and callable(widget.windowTitle)
+ and widget.windowTitle() == self.dialog_to_watch_for
+ ):
+ # Found the dialog we are looking for: now try to "click" the appropriate button
+ self.dialog_found = True
+ if self.dialog_found:
+ self.has_run = True
+ if self.interaction is not None and callable(self.interaction):
+ self.interaction(widget)
+
+
+class TestInstallerGui(unittest.TestCase):
+
+ MODULE = "test_installer_gui" # file name without extension
+
+ class MockAddon:
+ def __init__(self):
+ test_dir = os.path.join(
+ FreeCAD.getHomePath(), "Mod", "AddonManager", "AddonManagerTest", "data"
+ )
+ self.name = "MockAddon"
+ self.display_name = "Mock Addon"
+ self.url = os.path.join(test_dir, "test_simple_repo.zip")
+ self.branch = "main"
+
+ def setUp(self):
+ self.addon_to_install = TestInstallerGui.MockAddon()
+ self.installer_gui = AddonInstallerGUI(self.addon_to_install)
+ self.finalized_thread = False
+
+ def tearDown(self):
+ pass
+
+ def test_success_dialog(self):
+ # Pop the modal dialog and verify that it opens, and responds to an OK click
+ dialog_watcher = DialogWatcher(
+ translate("AddonsInstaller", "Success"), translate("AddonsInstaller", "OK")
+ )
+ QtCore.QTimer.singleShot(10, dialog_watcher.run)
+ self.installer_gui._installation_succeeded()
+ self.assertTrue(
+ dialog_watcher.dialog_found, "Failed to find the expected dialog box"
+ )
+
+ def test_failure_dialog(self):
+ # Pop the modal dialog and verify that it opens, and responds to a Cancel click
+ dialog_watcher = DialogWatcher(
+ translate("AddonsInstaller", "Installation Failed"),
+ translate("AddonsInstaller", "Cancel"),
+ )
+ QtCore.QTimer.singleShot(10, dialog_watcher.run)
+ self.installer_gui._installation_failed(
+ self.addon_to_install, "Test of installation failure"
+ )
+ self.assertTrue(
+ dialog_watcher.dialog_found, "Failed to find the expected dialog box"
+ )
+
+ def test_no_python_dialog(self):
+ # Pop the modal dialog and verify that it opens, and responds to a No click
+ dialog_watcher = DialogWatcher(
+ translate("AddonsInstaller", "Cannot execute Python"),
+ translate("AddonsInstaller", "No"),
+ )
+ QtCore.QTimer.singleShot(10, dialog_watcher.run)
+ self.installer_gui._report_no_python_exe()
+ self.assertTrue(
+ dialog_watcher.dialog_found, "Failed to find the expected dialog box"
+ )
+
+ def test_no_pip_dialog(self):
+ # Pop the modal dialog and verify that it opens, and responds to a No click
+ dialog_watcher = DialogWatcher(
+ translate("AddonsInstaller", "Cannot execute pip"),
+ translate("AddonsInstaller", "No"),
+ )
+ QtCore.QTimer.singleShot(10, dialog_watcher.run)
+ self.installer_gui._report_no_pip("pip not actually run, this was a test")
+ self.assertTrue(
+ dialog_watcher.dialog_found, "Failed to find the expected dialog box"
+ )
+
+ def test_dependency_failure_dialog(self):
+ # Pop the modal dialog and verify that it opens, and responds to a No click
+ dialog_watcher = DialogWatcher(
+ translate("AddonsInstaller", "Package installation failed"),
+ translate("AddonsInstaller", "No"),
+ )
+ QtCore.QTimer.singleShot(10, dialog_watcher.run)
+ self.installer_gui._report_dependency_failure(
+ "Unit test", "Nothing really failed, this is a test of the dialog box"
+ )
+ self.assertTrue(
+ dialog_watcher.dialog_found, "Failed to find the expected dialog box"
+ )
+
+ def test_install(self):
+ # Run the installation code and make sure it puts the directory in place
+ with tempfile.TemporaryDirectory() as temp_dir:
+ self.installer_gui.installer.installation_path = temp_dir
+ self.installer_gui.install() # This does not block
+ self.installer_gui.installer.success.disconnect(
+ self.installer_gui._installation_succeeded
+ )
+ self.installer_gui.installer.failure.disconnect(
+ self.installer_gui._installation_failed
+ )
+ while not self.installer_gui.worker_thread.isFinished():
+ QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 100)
+ self.assertTrue(
+ os.path.exists(os.path.join(temp_dir, "MockAddon")),
+ "Installed directory not found",
+ )
+
+ def test_handle_disallowed_python(self):
+ disallowed_packages = ["disallowed_package_name"]
+ dialog_watcher = DialogWatcher(
+ translate("AddonsInstaller", "Missing Requirement"),
+ translate("AddonsInstaller", "Cancel"),
+ )
+ QtCore.QTimer.singleShot(10, dialog_watcher.run)
+ self.installer_gui._handle_disallowed_python(disallowed_packages)
+ self.assertTrue(
+ dialog_watcher.dialog_found, "Failed to find the expected dialog box"
+ )
+
+ def test_handle_disallowed_python_long_list(self):
+ """A separate test for when there are MANY packages, which takes a separate code path."""
+ disallowed_packages = []
+ for i in range(50):
+ disallowed_packages.append(f"disallowed_package_name_{i}")
+ dialog_watcher = DialogWatcher(
+ translate("AddonsInstaller", "Missing Requirement"),
+ translate("AddonsInstaller", "Cancel"),
+ )
+ QtCore.QTimer.singleShot(10, dialog_watcher.run)
+ self.installer_gui._handle_disallowed_python(disallowed_packages)
+ self.assertTrue(
+ dialog_watcher.dialog_found, "Failed to find the expected dialog box"
+ )
+
+ def test_report_missing_workbenches_single(self):
+ """Test only missing one workbench"""
+ wbs = ["OneMissingWorkbench"]
+ dialog_watcher = DialogWatcher(
+ translate("AddonsInstaller", "Missing Requirement"),
+ translate("AddonsInstaller", "Cancel"),
+ )
+ QtCore.QTimer.singleShot(10, dialog_watcher.run)
+ self.installer_gui._report_missing_workbenches(wbs)
+ self.assertTrue(
+ dialog_watcher.dialog_found, "Failed to find the expected dialog box"
+ )
+
+ def test_report_missing_workbenches_multiple(self):
+ """Test only missing one workbench"""
+ wbs = ["FirstMissingWorkbench", "SecondMissingWorkbench"]
+ dialog_watcher = DialogWatcher(
+ translate("AddonsInstaller", "Missing Requirement"),
+ translate("AddonsInstaller", "Cancel"),
+ )
+ QtCore.QTimer.singleShot(10, dialog_watcher.run)
+ self.installer_gui._report_missing_workbenches(wbs)
+ self.assertTrue(
+ dialog_watcher.dialog_found, "Failed to find the expected dialog box"
+ )
+
+ def test_resolve_dependencies_then_install(self):
+ class MissingDependenciesMock:
+ def __init__(self):
+ self.external_addons = ["addon_1", "addon_2"]
+ self.python_requires = ["py_req_1", "py_req_2"]
+ self.python_optional = ["py_opt_1", "py_opt_2"]
+
+ missing = MissingDependenciesMock()
+ dialog_watcher = DialogWatcher(
+ translate("AddonsInstaller", "Resolve Dependencies"),
+ translate("AddonsInstaller", "Cancel"),
+ )
+ QtCore.QTimer.singleShot(10, dialog_watcher.run)
+ self.installer_gui._resolve_dependencies_then_install(missing)
+ self.assertTrue(
+ dialog_watcher.dialog_found, "Failed to find the expected dialog box"
+ )
+
+ def test_check_python_version_bad(self):
+ class MissingDependenciesMock:
+ def __init__(self):
+ self.python_min_version = {"major": 3, "minor": 9999}
+
+ missing = MissingDependenciesMock()
+ dialog_watcher = DialogWatcher(
+ translate("AddonsInstaller", "Incompatible Python version"),
+ translate("AddonsInstaller", "Cancel"),
+ )
+ QtCore.QTimer.singleShot(10, dialog_watcher.run)
+ stop_installing = self.installer_gui._check_python_version(missing)
+ self.assertTrue(
+ dialog_watcher.dialog_found, "Failed to find the expected dialog box"
+ )
+ self.assertTrue(
+ stop_installing, "Failed to halt installation on bad Python version"
+ )
+
+ def test_check_python_version_good(self):
+ class MissingDependenciesMock:
+ def __init__(self):
+ self.python_min_version = {"major": 3, "minor": 0}
+
+ missing = MissingDependenciesMock()
+ stop_installing = self.installer_gui._check_python_version(missing)
+ self.assertFalse(
+ stop_installing, "Failed to continue installation on good Python version"
+ )
+
+ def test_clean_up_optional(self):
+ class MissingDependenciesMock:
+ def __init__(self):
+ self.python_optional = [
+ "allowed_packages_1",
+ "allowed_packages_2",
+ "disallowed_package",
+ ]
+
+ allowed_packages = ["allowed_packages_1", "allowed_packages_2"]
+ missing = MissingDependenciesMock()
+ self.installer_gui.installer.allowed_packages = set(allowed_packages)
+ self.installer_gui._clean_up_optional(missing)
+ self.assertTrue("allowed_packages_1" in missing.python_optional)
+ self.assertTrue("allowed_packages_2" in missing.python_optional)
+ self.assertFalse("disallowed_package" in missing.python_optional)
+
+ def intercept_run_dependency_installer(
+ self, addons, python_requires, python_optional
+ ):
+ self.assertEqual(python_requires, ["py_req_1", "py_req_2"])
+ self.assertEqual(python_optional, ["py_opt_1", "py_opt_2"])
+ self.assertEqual(addons[0].name, "addon_1")
+ self.assertEqual(addons[1].name, "addon_2")
+
+ def test_dependency_dialog_yes_clicked(self):
+ class DialogMock:
+ class ListWidgetMock:
+ class ListWidgetItemMock:
+ def __init__(self, name):
+ self.name = name
+
+ def text(self):
+ return self.name
+
+ def checkState(self):
+ return QtCore.Qt.Checked
+
+ def __init__(self, items):
+ self.list = []
+ for item in items:
+ self.list.append(
+ DialogMock.ListWidgetMock.ListWidgetItemMock(item)
+ )
+
+ def count(self):
+ return len(self.list)
+
+ def item(self, i):
+ return self.list[i]
+
+ def __init__(self):
+ self.listWidgetAddons = DialogMock.ListWidgetMock(
+ ["addon_1", "addon_2"]
+ )
+ self.listWidgetPythonRequired = DialogMock.ListWidgetMock(
+ ["py_req_1", "py_req_2"]
+ )
+ self.listWidgetPythonOptional = DialogMock.ListWidgetMock(
+ ["py_opt_1", "py_opt_2"]
+ )
+
+ class AddonMock:
+ def __init__(self, name):
+ self.name = name
+
+ self.installer_gui.dependency_dialog = DialogMock()
+ self.installer_gui.addons = [AddonMock("addon_1"), AddonMock("addon_2")]
+ self.installer_gui._run_dependency_installer = (
+ self.intercept_run_dependency_installer
+ )
+ self.installer_gui._dependency_dialog_yes_clicked()
+
+
+class TestMacroInstallerGui(unittest.TestCase):
+ class MockMacroAddon:
+ class MockMacro:
+ def __init__(self):
+ self.install_called = False
+ self.install_result = (
+ True # External code can change to False to test failed install
+ )
+ self.name = "MockMacro"
+ self.filename = "mock_macro_no_real_file.FCMacro"
+ self.comment = "This is a mock macro for unit testing"
+ self.icon = None
+ self.xpm = None
+
+ def install(self):
+ self.install_called = True
+ return self.install_result
+
+ def __init__(self):
+ self.macro = TestMacroInstallerGui.MockMacroAddon.MockMacro()
+ self.name = self.macro.name
+ self.display_name = self.macro.name
+
+ class MockParameter:
+ """Mock the parameter group to allow simplified behavior and introspection."""
+
+ def __init__(self):
+ self.params = {}
+ self.groups = {}
+ self.accessed_parameters = {} # Dict is param name: default value
+
+ types = ["Bool", "String", "Int", "UInt", "Float"]
+ for t in types:
+ setattr(self, f"Get{t}", self.get)
+ setattr(self, f"Set{t}", self.set)
+ setattr(self, f"Rem{t}", self.rem)
+
+ def get(self, p, default=None):
+ self.accessed_parameters[p] = default
+ if p in self.params:
+ return self.params[p]
+ else:
+ return default
+
+ def set(self, p, value):
+ self.params[p] = value
+
+ def rem(self, p):
+ if p in self.params:
+ self.params.erase(p)
+
+ def GetGroup(self, name):
+ if name not in self.groups:
+ self.groups[name] = TestMacroInstallerGui.MockParameter()
+ return self.groups[name]
+
+ def GetGroups(self):
+ return self.groups.keys()
+
+ class ToolbarIntercepter:
+ def __init__(self):
+ self.ask_for_toolbar_called = False
+ self.install_macro_to_toolbar_called = False
+ self.tb = None
+ self.custom_group = TestMacroInstallerGui.MockParameter()
+ self.custom_group.set("Name", "MockCustomToolbar")
+
+ def _ask_for_toolbar(self, _):
+ self.ask_for_toolbar_called = True
+ return self.custom_group
+
+ def _install_macro_to_toolbar(self, tb):
+ self.install_macro_to_toolbar_called = True
+ self.tb = tb
+
+ class InstallerInterceptor:
+ def __init__(self):
+ self.ccc_called = False
+
+ def _create_custom_command(
+ self,
+ toolbar,
+ filename,
+ menuText,
+ tooltipText,
+ whatsThisText,
+ statustipText,
+ pixmapText,
+ ):
+ self.ccc_called = True
+ self.toolbar = toolbar
+ self.filename = filename
+ self.menuText = menuText
+ self.tooltipText = tooltipText
+ self.whatsThisText = whatsThisText
+ self.statustipText = statustipText
+ self.pixmapText = pixmapText
+
+ def setUp(self):
+ self.mock_macro = TestMacroInstallerGui.MockMacroAddon()
+ self.installer = MacroInstallerGUI(self.mock_macro)
+ self.installer.addon_params = TestMacroInstallerGui.MockParameter()
+ self.installer.toolbar_params = TestMacroInstallerGui.MockParameter()
+
+ def tearDown(self):
+ pass
+
+ def test_ask_for_toolbar_no_dialog_default_exists(self):
+ self.installer.addon_params.set("alwaysAskForToolbar", False)
+ self.installer.addon_params.set("CustomToolbarName", "UnitTestCustomToolbar")
+ utct = self.installer.toolbar_params.GetGroup("UnitTestCustomToolbar")
+ utct.set("Name", "UnitTestCustomToolbar")
+ utct.set("Active", True)
+ result = self.installer._ask_for_toolbar([])
+ self.assertIsNotNone(result)
+ self.assertTrue(hasattr(result, "get"))
+ name = result.get("Name")
+ self.assertEqual(name, "UnitTestCustomToolbar")
+
+ def test_ask_for_toolbar_with_dialog_cancelled(self):
+
+ # First test: the user cancels the dialog
+ self.installer.addon_params.set("alwaysAskForToolbar", True)
+ dialog_watcher = DialogWatcher(
+ translate("AddonsInstaller", "Select Toolbar"),
+ translate("AddonsInstaller", "Cancel"),
+ )
+ QtCore.QTimer.singleShot(10, dialog_watcher.run)
+ result = self.installer._ask_for_toolbar([])
+ self.assertIsNone(result)
+
+ def test_ask_for_toolbar_with_dialog_defaults(self):
+
+ # Second test: the user leaves the dialog at all default values, so:
+ # - The checkbox "Ask every time" is unchecked
+ # - The selected toolbar option is "Create new toolbar", which triggers a search for
+ # a new custom toolbar name by calling _create_new_custom_toolbar, which we mock.
+ dialog_watcher = DialogWatcher(
+ translate("AddonsInstaller", "Select Toolbar"),
+ translate("AddonsInstaller", "Cancel"),
+ )
+ QtCore.QTimer.singleShot(10, dialog_watcher.run)
+ fake_custom_toolbar_group = TestMacroInstallerGui.MockParameter()
+ fake_custom_toolbar_group.set("Name", "UnitTestCustomToolbar")
+ self.installer._create_new_custom_toolbar = lambda: fake_custom_toolbar_group
+ dialog_watcher = DialogWatcher(
+ translate("AddonsInstaller", "Select Toolbar"),
+ translate("AddonsInstaller", "OK"),
+ )
+ QtCore.QTimer.singleShot(10, dialog_watcher.run)
+ result = self.installer._ask_for_toolbar([])
+ self.assertIsNotNone(result)
+ self.assertTrue(hasattr(result, "get"))
+ name = result.get("Name")
+ self.assertEqual(name, "UnitTestCustomToolbar")
+ self.assertIn("alwaysAskForToolbar", self.installer.addon_params.params)
+ self.assertFalse(self.installer.addon_params.get("alwaysAskForToolbar", True))
+
+ def test_ask_for_toolbar_with_dialog_selection(self):
+
+ # Third test: the user selects a custom toolbar in the dialog, and checks the box to always
+ # ask.
+ dialog_interactor = DialogInteractor(
+ translate("AddonsInstaller", "Select Toolbar"),
+ self.interactor_selection_option_and_checkbox,
+ )
+ QtCore.QTimer.singleShot(10, dialog_interactor.run)
+ ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
+ ut_tb_2 = self.installer.toolbar_params.GetGroup("UT_TB_2")
+ ut_tb_3 = self.installer.toolbar_params.GetGroup("UT_TB_3")
+ ut_tb_1.set("Name", "UT_TB_1")
+ ut_tb_2.set("Name", "UT_TB_2")
+ ut_tb_3.set("Name", "UT_TB_3")
+ result = self.installer._ask_for_toolbar(["UT_TB_1", "UT_TB_2", "UT_TB_3"])
+ self.assertIsNotNone(result)
+ self.assertTrue(hasattr(result, "get"))
+ name = result.get("Name")
+ self.assertEqual(name, "UT_TB_3")
+ self.assertIn("alwaysAskForToolbar", self.installer.addon_params.params)
+ self.assertTrue(self.installer.addon_params.get("alwaysAskForToolbar", False))
+
+ def interactor_selection_option_and_checkbox(self, parent):
+
+ boxes = parent.findChildren(QtWidgets.QComboBox)
+ self.assertEqual(len(boxes), 1) # Just to make sure...
+ box = boxes[0]
+ box.setCurrentIndex(box.count() - 2) # Select the last thing but one
+
+ checkboxes = parent.findChildren(QtWidgets.QCheckBox)
+ self.assertEqual(len(checkboxes), 1) # Just to make sure...
+ checkbox = checkboxes[0]
+ checkbox.setChecked(True)
+
+ parent.accept()
+
+ def test_macro_button_exists_no_command(self):
+ # Test 1: No command for this macro
+ self.installer._find_custom_command = lambda _: None
+ button_exists = self.installer._macro_button_exists()
+ self.assertFalse(button_exists)
+
+ def test_macro_button_exists_true(self):
+ # Test 2: Macro is in the list of buttons
+ ut_tb_1 = self.installer.toolbar_params.GetGroup("UnitTestCommand")
+ ut_tb_1.set(
+ "UnitTestCommand", "FreeCAD"
+ ) # This is what the real thing looks like...
+ self.installer._find_custom_command = lambda _: "UnitTestCommand"
+ self.assertTrue(self.installer._macro_button_exists())
+
+ def test_macro_button_exists_false(self):
+ # Test 3: Macro is not in the list of buttons
+ self.installer._find_custom_command = lambda _: "UnitTestCommand"
+ self.assertFalse(self.installer._macro_button_exists())
+
+ def test_ask_to_install_toolbar_button_disabled(self):
+ self.installer.addon_params.SetBool("dontShowAddMacroButtonDialog", True)
+ self.installer._ask_to_install_toolbar_button()
+ # This should NOT block when dontShowAddMacroButtonDialog is True
+
+ def test_ask_to_install_toolbar_button_enabled_no(self):
+ self.installer.addon_params.SetBool("dontShowAddMacroButtonDialog", False)
+ dialog_watcher = DialogWatcher(
+ translate("AddonsInstaller", "Add button?"),
+ translate("AddonsInstaller", "No"),
+ )
+ QtCore.QTimer.singleShot(10, dialog_watcher.run)
+ self.installer._ask_to_install_toolbar_button() # Blocks until killed by watcher
+ self.assertTrue(dialog_watcher.dialog_found)
+
+ def test_get_toolbar_with_name_found(self):
+ ut_tb_1 = self.installer.toolbar_params.GetGroup("UnitTestToolbar")
+ ut_tb_1.set("Name", "Unit Test Toolbar")
+ ut_tb_1.set("UnitTestParam", True)
+ tb = self.installer._get_toolbar_with_name("Unit Test Toolbar")
+ self.assertIsNotNone(tb)
+ self.assertTrue(tb.get("UnitTestParam", False))
+
+ def test_get_toolbar_with_name_not_found(self):
+ ut_tb_1 = self.installer.toolbar_params.GetGroup("UnitTestToolbar")
+ ut_tb_1.set("Name", "Not the Unit Test Toolbar")
+ tb = self.installer._get_toolbar_with_name("Unit Test Toolbar")
+ self.assertIsNone(tb)
+
+ def test_create_new_custom_toolbar_no_existing(self):
+ tb = self.installer._create_new_custom_toolbar()
+ self.assertEqual(tb.get("Name", ""), "Auto-Created Macro Toolbar")
+ self.assertTrue(tb.get("Active", False), True)
+
+ def test_create_new_custom_toolbar_one_existing(self):
+ _ = self.installer._create_new_custom_toolbar()
+ tb = self.installer._create_new_custom_toolbar()
+ self.assertEqual(tb.get("Name", ""), "Auto-Created Macro Toolbar (2)")
+ self.assertTrue(tb.get("Active", False), True)
+
+ def test_check_for_toolbar_true(self):
+ ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
+ ut_tb_1.set("Name", "UT_TB_1")
+ self.assertTrue(self.installer._check_for_toolbar("UT_TB_1"))
+
+ def test_check_for_toolbar_false(self):
+ ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
+ ut_tb_1.set("Name", "UT_TB_1")
+ self.assertFalse(self.installer._check_for_toolbar("Not UT_TB_1"))
+
+ def test_install_toolbar_button_first_custom_toolbar(self):
+ tbi = TestMacroInstallerGui.ToolbarIntercepter()
+ self.installer._ask_for_toolbar = tbi._ask_for_toolbar
+ self.installer._install_macro_to_toolbar = tbi._install_macro_to_toolbar
+ self.installer._install_toolbar_button()
+ self.assertTrue(tbi.install_macro_to_toolbar_called)
+ self.assertFalse(tbi.ask_for_toolbar_called)
+ self.assertTrue("Custom_1" in self.installer.toolbar_params.GetGroups())
+
+ def test_install_toolbar_button_existing_custom_toolbar_1(self):
+ # There is an existing custom toolbar, and we should use it
+ tbi = TestMacroInstallerGui.ToolbarIntercepter()
+ self.installer._ask_for_toolbar = tbi._ask_for_toolbar
+ self.installer._install_macro_to_toolbar = tbi._install_macro_to_toolbar
+ ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
+ ut_tb_1.set("Name", "UT_TB_1")
+ self.installer.addon_params.set("CustomToolbarName", "UT_TB_1")
+ self.installer._install_toolbar_button()
+ self.assertTrue(tbi.install_macro_to_toolbar_called)
+ self.assertFalse(tbi.ask_for_toolbar_called)
+ self.assertEqual(tbi.tb.get("Name", ""), "UT_TB_1")
+
+ def test_install_toolbar_button_existing_custom_toolbar_2(self):
+ # There are multiple existing custom toolbars, and we should use one of them
+ tbi = TestMacroInstallerGui.ToolbarIntercepter()
+ self.installer._ask_for_toolbar = tbi._ask_for_toolbar
+ self.installer._install_macro_to_toolbar = tbi._install_macro_to_toolbar
+ ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
+ ut_tb_2 = self.installer.toolbar_params.GetGroup("UT_TB_2")
+ ut_tb_3 = self.installer.toolbar_params.GetGroup("UT_TB_3")
+ ut_tb_1.set("Name", "UT_TB_1")
+ ut_tb_2.set("Name", "UT_TB_2")
+ ut_tb_3.set("Name", "UT_TB_3")
+ self.installer.addon_params.set("CustomToolbarName", "UT_TB_3")
+ self.installer._install_toolbar_button()
+ self.assertTrue(tbi.install_macro_to_toolbar_called)
+ self.assertFalse(tbi.ask_for_toolbar_called)
+ self.assertEqual(tbi.tb.get("Name", ""), "UT_TB_3")
+
+ def test_install_toolbar_button_existing_custom_toolbar_3(self):
+ # There are multiple existing custom toolbars, but none of them match
+ tbi = TestMacroInstallerGui.ToolbarIntercepter()
+ self.installer._ask_for_toolbar = tbi._ask_for_toolbar
+ self.installer._install_macro_to_toolbar = tbi._install_macro_to_toolbar
+ ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
+ ut_tb_2 = self.installer.toolbar_params.GetGroup("UT_TB_2")
+ ut_tb_3 = self.installer.toolbar_params.GetGroup("UT_TB_3")
+ ut_tb_1.set("Name", "UT_TB_1")
+ ut_tb_2.set("Name", "UT_TB_2")
+ ut_tb_3.set("Name", "UT_TB_3")
+ self.installer.addon_params.set("CustomToolbarName", "UT_TB_4")
+ self.installer._install_toolbar_button()
+ self.assertTrue(tbi.install_macro_to_toolbar_called)
+ self.assertTrue(tbi.ask_for_toolbar_called)
+ self.assertEqual(tbi.tb.get("Name", ""), "MockCustomToolbar")
+
+ def test_install_toolbar_button_existing_custom_toolbar_4(self):
+ # There are multiple existing custom toolbars, one of them matches, but we have set
+ # "alwaysAskForToolbar" to True
+ tbi = TestMacroInstallerGui.ToolbarIntercepter()
+ self.installer._ask_for_toolbar = tbi._ask_for_toolbar
+ self.installer._install_macro_to_toolbar = tbi._install_macro_to_toolbar
+ ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
+ ut_tb_2 = self.installer.toolbar_params.GetGroup("UT_TB_2")
+ ut_tb_3 = self.installer.toolbar_params.GetGroup("UT_TB_3")
+ ut_tb_1.set("Name", "UT_TB_1")
+ ut_tb_2.set("Name", "UT_TB_2")
+ ut_tb_3.set("Name", "UT_TB_3")
+ self.installer.addon_params.set("CustomToolbarName", "UT_TB_3")
+ self.installer.addon_params.set("alwaysAskForToolbar", True)
+ self.installer._install_toolbar_button()
+ self.assertTrue(tbi.install_macro_to_toolbar_called)
+ self.assertTrue(tbi.ask_for_toolbar_called)
+ self.assertEqual(tbi.tb.get("Name", ""), "MockCustomToolbar")
+
+ def test_install_macro_to_toolbar_icon_abspath(self):
+ ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
+ ut_tb_1.set("Name", "UT_TB_1")
+ ii = TestMacroInstallerGui.InstallerInterceptor()
+ self.installer._create_custom_command = ii._create_custom_command
+ with tempfile.NamedTemporaryFile() as ntf:
+ self.mock_macro.macro.icon = ntf.name
+ self.installer._install_macro_to_toolbar(ut_tb_1)
+ self.assertTrue(ii.ccc_called)
+ self.assertEqual(ii.pixmapText, ntf.name)
+
+ def test_install_macro_to_toolbar_icon_relpath(self):
+ ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
+ ut_tb_1.set("Name", "UT_TB_1")
+ ii = TestMacroInstallerGui.InstallerInterceptor()
+ self.installer._create_custom_command = ii._create_custom_command
+ with tempfile.TemporaryDirectory() as td:
+ self.installer.macro_dir = td
+ self.mock_macro.macro.icon = "RelativeIconPath.png"
+ self.installer._install_macro_to_toolbar(ut_tb_1)
+ self.assertTrue(ii.ccc_called)
+ self.assertEqual(ii.pixmapText, os.path.join(td, "RelativeIconPath.png"))
+
+ def test_install_macro_to_toolbar_xpm(self):
+ ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
+ ut_tb_1.set("Name", "UT_TB_1")
+ ii = TestMacroInstallerGui.InstallerInterceptor()
+ self.installer._create_custom_command = ii._create_custom_command
+ with tempfile.TemporaryDirectory() as td:
+ self.installer.macro_dir = td
+ self.mock_macro.macro.xpm = "Not really xpm data, don't try to use it!"
+ self.installer._install_macro_to_toolbar(ut_tb_1)
+ self.assertTrue(ii.ccc_called)
+ self.assertEqual(ii.pixmapText, os.path.join(td, "MockMacro_icon.xpm"))
+ self.assertTrue(os.path.exists(os.path.join(td, "MockMacro_icon.xpm")))
+
+ def test_install_macro_to_toolbar_no_icon(self):
+ ut_tb_1 = self.installer.toolbar_params.GetGroup("UT_TB_1")
+ ut_tb_1.set("Name", "UT_TB_1")
+ ii = TestMacroInstallerGui.InstallerInterceptor()
+ self.installer._create_custom_command = ii._create_custom_command
+ with tempfile.TemporaryDirectory() as td:
+ self.installer.macro_dir = td
+ self.installer._install_macro_to_toolbar(ut_tb_1)
+ self.assertTrue(ii.ccc_called)
+ self.assertIsNone(ii.pixmapText)
diff --git a/src/Mod/AddonManager/AddonManagerTest/gui/test_update_all_gui.py b/src/Mod/AddonManager/AddonManagerTest/gui/test_update_all_gui.py
new file mode 100644
index 0000000000..af40eb30a5
--- /dev/null
+++ b/src/Mod/AddonManager/AddonManagerTest/gui/test_update_all_gui.py
@@ -0,0 +1,246 @@
+# ***************************************************************************
+# * *
+# * 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 *
+# * . *
+# * *
+# ***************************************************************************
+
+from time import sleep
+import unittest
+import FreeCAD
+
+from Addon import Addon
+
+from PySide import QtCore, QtWidgets
+
+from addonmanager_update_all_gui import UpdateAllGUI, AddonStatus
+
+
+class MockUpdater(QtCore.QObject):
+ success = QtCore.Signal(object)
+ failure = QtCore.Signal(object)
+ finished = QtCore.Signal()
+
+ def __init__(self, addon, addons=[]):
+ super().__init__()
+ self.addon_to_install = addon
+ self.addons = addons
+ self.has_run = False
+ self.emit_success = True
+ self.work_function = (
+ None # Set to some kind of callable to make this function take time
+ )
+
+ def run(self):
+ self.has_run = True
+ if self.work_function is not None and callable(self.work_function):
+ self.work_function()
+ if self.emit_success:
+ self.success.emit(self.addon_to_install)
+ else:
+ self.failure.emit(self.addon_to_install)
+ self.finished.emit()
+
+
+class MockUpdaterFactory:
+ def __init__(self, addons):
+ self.addons = addons
+ self.work_function = None
+ self.updater = None
+
+ def get_updater(self, addon):
+ self.updater = MockUpdater(addon, self.addons)
+ self.updater.work_function = self.work_function
+ return self.updater
+
+
+class MockAddon:
+ def __init__(self, name):
+ self.display_name = name
+ self.name = name
+ self.macro = None
+
+ def status(self):
+ return Addon.Status.UPDATE_AVAILABLE
+
+
+class CallInterceptor:
+ def __init__(self):
+ self.called = False
+ self.args = None
+
+ def intercept(self, *args):
+ self.called = True
+ self.args = args
+
+
+class TestUpdateAllGui(unittest.TestCase):
+ def setUp(self):
+ self.addons = []
+ for i in range(3):
+ self.addons.append(MockAddon(f"Mock Addon {i}"))
+ self.factory = MockUpdaterFactory(self.addons)
+ self.test_object = UpdateAllGUI(self.addons)
+ self.test_object.updater_factory = self.factory
+
+ def tearDown(self):
+ pass
+
+ def test_run(self):
+ self.factory.work_function = lambda: sleep(0.1)
+ self.test_object.run()
+ while self.test_object.is_running():
+ QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 100)
+ self.test_object.dialog.accept()
+
+ def test_setup_dialog(self):
+ self.test_object._setup_dialog()
+ self.assertIsNotNone(
+ self.test_object.dialog.buttonBox.button(QtWidgets.QDialogButtonBox.Cancel)
+ )
+ self.assertEqual(self.test_object.dialog.tableWidget.rowCount(), 3)
+
+ def test_cancelling_installation(self):
+ self.factory.work_function = lambda: sleep(0.1)
+ self.test_object.run()
+ cancel_timer = QtCore.QTimer()
+ cancel_timer.timeout.connect(
+ self.test_object.dialog.buttonBox.button(
+ QtWidgets.QDialogButtonBox.Cancel
+ ).click
+ )
+ cancel_timer.start(90)
+ while self.test_object.is_running():
+ QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 10)
+ self.assertGreater(len(self.test_object.addons_with_update), 0)
+
+ def test_add_addon_to_table(self):
+ mock_addon = MockAddon("MockAddon")
+ self.test_object.dialog.tableWidget.clear()
+ self.test_object._add_addon_to_table(mock_addon)
+ self.assertEqual(self.test_object.dialog.tableWidget.rowCount(), 1)
+
+ def test_update_addon_status(self):
+ self.test_object._setup_dialog()
+ self.test_object._update_addon_status(0, AddonStatus.WAITING)
+ self.assertEqual(
+ self.test_object.dialog.tableWidget.item(0, 1).text(),
+ AddonStatus.WAITING.ui_string(),
+ )
+ self.test_object._update_addon_status(0, AddonStatus.INSTALLING)
+ self.assertEqual(
+ self.test_object.dialog.tableWidget.item(0, 1).text(),
+ AddonStatus.INSTALLING.ui_string(),
+ )
+ self.test_object._update_addon_status(0, AddonStatus.SUCCEEDED)
+ self.assertEqual(
+ self.test_object.dialog.tableWidget.item(0, 1).text(),
+ AddonStatus.SUCCEEDED.ui_string(),
+ )
+ self.test_object._update_addon_status(0, AddonStatus.FAILED)
+ self.assertEqual(
+ self.test_object.dialog.tableWidget.item(0, 1).text(),
+ AddonStatus.FAILED.ui_string(),
+ )
+
+ def test_process_next_update(self):
+ self.test_object._setup_dialog()
+ self.test_object._launch_active_installer = lambda: None
+ self.test_object._process_next_update()
+ self.assertEqual(
+ self.test_object.dialog.tableWidget.item(0, 1).text(),
+ AddonStatus.INSTALLING.ui_string(),
+ )
+
+ self.test_object._process_next_update()
+ self.assertEqual(
+ self.test_object.dialog.tableWidget.item(1, 1).text(),
+ AddonStatus.INSTALLING.ui_string(),
+ )
+
+ self.test_object._process_next_update()
+ self.assertEqual(
+ self.test_object.dialog.tableWidget.item(2, 1).text(),
+ AddonStatus.INSTALLING.ui_string(),
+ )
+
+ self.test_object._process_next_update()
+
+ def test_launch_active_installer(self):
+ self.test_object.active_installer = self.factory.get_updater(self.addons[0])
+ self.test_object._update_succeeded = lambda _: None
+ self.test_object._update_failed = lambda _: None
+ self.test_object.process_next_update = lambda: None
+ self.test_object._launch_active_installer()
+ # The above call does not block, so spin until it has completed (basically instantly in testing)
+ while self.test_object.worker_thread.isRunning():
+ QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 100)
+ self.test_object.dialog.accept()
+
+ def test_update_succeeded(self):
+ self.test_object._setup_dialog()
+ self.test_object._update_succeeded(self.addons[0])
+ self.assertEqual(
+ self.test_object.dialog.tableWidget.item(0, 1).text(),
+ AddonStatus.SUCCEEDED.ui_string(),
+ )
+
+ def test_update_failed(self):
+ self.test_object._setup_dialog()
+ self.test_object._update_failed(self.addons[0])
+ self.assertEqual(
+ self.test_object.dialog.tableWidget.item(0, 1).text(),
+ AddonStatus.FAILED.ui_string(),
+ )
+
+ def test_update_finished(self):
+ self.test_object._setup_dialog()
+ call_interceptor = CallInterceptor()
+ self.test_object.worker_thread = QtCore.QThread()
+ self.test_object.worker_thread.start()
+ self.test_object._process_next_update = call_interceptor.intercept
+ self.test_object.active_installer = self.factory.get_updater(self.addons[0])
+ self.test_object._update_finished()
+ self.assertFalse(self.test_object.worker_thread.isRunning())
+ self.test_object.worker_thread.terminate()
+ self.assertTrue(call_interceptor.called)
+ self.test_object.worker_thread.wait()
+
+ def test_finalize(self):
+ self.test_object._setup_dialog()
+ self.test_object.worker_thread = QtCore.QThread()
+ self.test_object.worker_thread.start()
+ self.test_object._finalize()
+ self.assertFalse(self.test_object.worker_thread.isRunning())
+ self.test_object.worker_thread.terminate()
+ self.test_object.worker_thread.wait()
+ self.assertFalse(self.test_object.running)
+ self.assertIsNotNone(
+ self.test_object.dialog.buttonBox.button(QtWidgets.QDialogButtonBox.Close)
+ )
+ self.assertIsNone(
+ self.test_object.dialog.buttonBox.button(QtWidgets.QDialogButtonBox.Cancel)
+ )
+
+ def test_is_running(self):
+ self.assertFalse(self.test_object.is_running())
+ self.test_object.run()
+ self.assertTrue(self.test_object.is_running())
+ while self.test_object.is_running():
+ QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 100)
+ self.test_object.dialog.accept()
diff --git a/src/Mod/AddonManager/AddonManagerTest/gui/test_workers_installation.py b/src/Mod/AddonManager/AddonManagerTest/gui/test_workers_installation.py
deleted file mode 100644
index 637cf1cd93..0000000000
--- a/src/Mod/AddonManager/AddonManagerTest/gui/test_workers_installation.py
+++ /dev/null
@@ -1,211 +0,0 @@
-# -*- coding: utf-8 -*-
-
-# ***************************************************************************
-# * Copyright (c) 2022 FreeCAD Project Association *
-# * *
-# * This file is part of the FreeCAD CAx development system. *
-# * *
-# * This library 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. *
-# * *
-# * This library 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 this library; if not, write to the Free Software *
-# * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA *
-# * 02110-1301 USA *
-# * *
-# ***************************************************************************
-
-import json
-import os
-import shutil
-import stat
-import tempfile
-import unittest
-import FreeCAD
-from addonmanager_git import initialize_git
-
-from PySide2 import QtCore
-
-import NetworkManager
-from Addon import Addon
-from addonmanager_workers_startup import (
- CreateAddonListWorker,
- UpdateChecker,
-)
-from addonmanager_workers_installation import InstallWorkbenchWorker
-
-
-class TestWorkersInstallation(unittest.TestCase):
-
- MODULE = "test_workers_installation" # file name without extension
-
- addon_list = (
- []
- ) # Cache at the class level so only the first test has to download it
-
- def setUp(self):
- """Set up the test"""
- self.test_dir = os.path.join(
- FreeCAD.getHomePath(), "Mod", "AddonManager", "AddonManagerTest", "data"
- )
-
- self.saved_mod_directory = Addon.mod_directory
- self.saved_cache_directory = Addon.cache_directory
- Addon.mod_directory = os.path.join(
- tempfile.gettempdir(), "FreeCADTesting", "Mod"
- )
- Addon.cache_directory = os.path.join(
- tempfile.gettempdir(), "FreeCADTesting", "Cache"
- )
-
- os.makedirs(Addon.mod_directory, mode=0o777, exist_ok=True)
- os.makedirs(Addon.cache_directory, mode=0o777, exist_ok=True)
-
- url = "https://api.github.com/zen"
- NetworkManager.InitializeNetworkManager()
- result = NetworkManager.AM_NETWORK_MANAGER.blocking_get(url)
- if result is None:
- self.skipTest("No active internet connection detected")
-
- self.macro_counter = 0
- self.workbench_counter = 0
- self.prefpack_counter = 0
- self.addon_from_cache_counter = 0
- self.macro_from_cache_counter = 0
-
- self.package_cache = {}
- self.macro_cache = []
-
- self.package_cache_filename = os.path.join(
- Addon.cache_directory, "packages.json"
- )
- self.macro_cache_filename = os.path.join(Addon.cache_directory, "macros.json")
-
- if not TestWorkersInstallation.addon_list:
- self._create_addon_list()
-
- # Workbench: use the FreeCAD-Help workbench for testing purposes
- self.help_addon = None
- for addon in self.addon_list:
- if addon.name == "Help":
- self.help_addon = addon
- break
- if not self.help_addon:
- print("Unable to locate the FreeCAD-Help addon to test with")
- self.skipTest("No active internet connection detected")
-
- # Store the user's preference for whether git is enabled or disabled
- pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
- self.saved_git_disabled_status = pref.GetBool("disableGit", False)
-
- def tearDown(self):
- mod_dir = os.path.join(tempfile.gettempdir(), "FreeCADTesting", "Mod")
- if os.path.exists(mod_dir):
- self._rmdir(mod_dir)
- macro_dir = os.path.join(tempfile.gettempdir(), "FreeCADTesting", "Mod")
- if os.path.exists(macro_dir):
- self._rmdir(macro_dir)
- pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
- pref.SetBool("disableGit", self.saved_git_disabled_status)
-
- def test_workbench_installation(self):
- addon_location = os.path.join(
- tempfile.gettempdir(), "FreeCADTesting", "Mod", self.help_addon.name
- )
- worker = InstallWorkbenchWorker(self.help_addon, addon_location)
- worker.run() # Synchronous call, blocks until complete
- self.assertTrue(os.path.exists(addon_location))
- self.assertTrue(os.path.exists(os.path.join(addon_location, "package.xml")))
-
- def test_workbench_installation_git_disabled(self):
- """If the testing user has git enabled, also test the addon manager with git disabled"""
- if self.saved_git_disabled_status:
- self.skipTest("Git is disabled, this test is redundant")
-
- pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons")
- pref.SetBool("disableGit", True)
-
- self.test_workbench_installation()
-
- pref.SetBool("disableGit", False)
-
- def test_workbench_update_checker(self):
-
- git_manager = initialize_git()
-
- if not git_manager:
- return
-
- # Workbench: use the FreeCAD-Help workbench for testing purposes
- help_addon = None
- for addon in self.addon_list:
- if addon.name == "Help":
- help_addon = addon
- break
- if not help_addon:
- print("Unable to locate the FreeCAD-Help addon to test with")
- return
-
- addon_location = os.path.join(
- tempfile.gettempdir(), "FreeCADTesting", "Mod", self.help_addon.name
- )
- worker = InstallWorkbenchWorker(addon, addon_location)
- worker.run() # Synchronous call, blocks until complete
- self.assertEqual(help_addon.status(), Addon.Status.PENDING_RESTART)
-
- # Back up one revision
- git_manager.reset(addon_location, ["--hard", "HEAD~1"])
-
- # At this point the addon should be "out of date", checked out to one revision behind
- # the most recent.
-
- worker = UpdateChecker()
- worker.override_mod_directory(
- os.path.join(tempfile.gettempdir(), "FreeCADTesting", "Mod")
- )
- worker.check_workbench(help_addon) # Synchronous call
- self.assertEqual(help_addon.status(), Addon.Status.UPDATE_AVAILABLE)
-
- # Now try to "update" it (which is really done via the install worker)
- worker = InstallWorkbenchWorker(addon, addon_location)
- worker.run() # Synchronous call, blocks until complete
- self.assertEqual(help_addon.status(), Addon.Status.PENDING_RESTART)
-
- def _rmdir(self, path):
- try:
- shutil.rmtree(path, onerror=self._remove_readonly)
- except Exception as e:
- print(e)
-
- def _remove_readonly(self, func, path, _) -> None:
- """Remove a read-only file."""
-
- os.chmod(path, stat.S_IWRITE)
- func(path)
-
- def _create_addon_list(self):
- """Create the list of addons"""
- worker = CreateAddonListWorker()
- worker.addon_repo.connect(self._addon_added)
- worker.start()
- while worker.isRunning():
- QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 50)
- QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents)
-
- def _addon_added(self, addon: Addon):
- """Callback for adding an Addon: tracks the list, and counts the various types"""
- print(f"Addon added: {addon.name}")
- TestWorkersInstallation.addon_list.append(addon)
- if addon.contains_workbench():
- self.workbench_counter += 1
- if addon.contains_macro():
- self.macro_counter += 1
- if addon.contains_preference_pack():
- self.prefpack_counter += 1
diff --git a/src/Mod/AddonManager/AddonManagerTest/gui/test_workers_startup.py b/src/Mod/AddonManager/AddonManagerTest/gui/test_workers_startup.py
index 0043741529..b659dc6760 100644
--- a/src/Mod/AddonManager/AddonManagerTest/gui/test_workers_startup.py
+++ b/src/Mod/AddonManager/AddonManagerTest/gui/test_workers_startup.py
@@ -27,8 +27,6 @@ import unittest
import os
import tempfile
-from addonmanager_git import initialize_git
-
import FreeCAD
from PySide2 import QtCore
@@ -39,11 +37,6 @@ from addonmanager_workers_startup import (
CreateAddonListWorker,
LoadPackagesFromCacheWorker,
LoadMacrosFromCacheWorker,
- CheckSingleUpdateWorker,
-)
-
-from addonmanager_workers_installation import (
- InstallWorkbenchWorker,
)
@@ -131,7 +124,6 @@ class TestWorkersStartup(unittest.TestCase):
f.write(json.dumps(self.macro_cache, indent=" "))
original_macro_counter = self.macro_counter
- original_workbench_counter = self.workbench_counter
original_addon_list = self.addon_list.copy()
self.macro_counter = 0
self.workbench_counter = 0
diff --git a/src/Mod/AddonManager/CMakeLists.txt b/src/Mod/AddonManager/CMakeLists.txt
index 03ef4e2f1e..0ea4b04e62 100644
--- a/src/Mod/AddonManager/CMakeLists.txt
+++ b/src/Mod/AddonManager/CMakeLists.txt
@@ -8,6 +8,7 @@ SET(AddonManager_SRCS
AddonManager.py
AddonManager.ui
addonmanager_connection_checker.py
+ addonmanager_dependency_installer.py
addonmanager_devmode.py
addonmanager_devmode_add_content.py
addonmanager_devmode_license_selector.py
@@ -19,7 +20,10 @@ SET(AddonManager_SRCS
addonmanager_devmode_validators.py
addonmanager_firstrun.py
addonmanager_git.py
+ addonmanager_installer.py
+ addonmanager_installer_gui.py
addonmanager_macro.py
+ addonmanager_update_all_gui.py
addonmanager_utilities.py
addonmanager_workers_installation.py
addonmanager_workers_startup.py
@@ -58,6 +62,7 @@ SET(AddonManager_SRCS
PythonDependencyUpdateDialog.ui
select_toolbar_dialog.ui
TestAddonManagerApp.py
+ update_all.ui
)
IF (BUILD_GUI)
LIST(APPEND AddonManager_SRCS TestAddonManagerGui.py)
@@ -73,15 +78,19 @@ SET(AddonManagerTests_SRCS
SET(AddonManagerTestsApp_SRCS
AddonManagerTest/app/__init__.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
)
SET(AddonManagerTestsGui_SRCS
AddonManagerTest/gui/__init__.py
+ AddonManagerTest/gui/gui_mocks.py
AddonManagerTest/gui/test_gui.py
- AddonManagerTest/gui/test_workers_installation.py
+ AddonManagerTest/gui/test_installer_gui.py
+ AddonManagerTest/gui/test_update_all_gui.py
AddonManagerTest/gui/test_workers_startup.py
AddonManagerTest/gui/test_workers_utility.py
)
@@ -98,7 +107,10 @@ SET(AddonManagerTestsFiles_SRCS
AddonManagerTest/data/macro_template.FCStd
AddonManagerTest/data/missing_macro_metadata.FCStd
AddonManagerTest/data/prefpack_only.xml
+ AddonManagerTest/data/test_addon_with_fcmacro.zip
+ AddonManagerTest/data/test_github_style_repo.zip
AddonManagerTest/data/test_repo.zip
+ AddonManagerTest/data/test_simple_repo.zip
AddonManagerTest/data/TestWorkbench.zip
AddonManagerTest/data/test_version_detection.xml
AddonManagerTest/data/workbench_only.xml
diff --git a/src/Mod/AddonManager/Init.py b/src/Mod/AddonManager/Init.py
index 7411e7f661..95843fa807 100644
--- a/src/Mod/AddonManager/Init.py
+++ b/src/Mod/AddonManager/Init.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
# FreeCAD init script of the AddonManager module
# (c) 2001 Juergen Riegel
# License LGPL
diff --git a/src/Mod/AddonManager/InitGui.py b/src/Mod/AddonManager/InitGui.py
index 7bec7cb34c..dc20c63ed5 100644
--- a/src/Mod/AddonManager/InitGui.py
+++ b/src/Mod/AddonManager/InitGui.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
# AddonManager gui init module
# (c) 2001 Juergen Riegel
# License LGPL
diff --git a/src/Mod/AddonManager/NetworkManager.py b/src/Mod/AddonManager/NetworkManager.py
index ec542f21be..6b74124975 100644
--- a/src/Mod/AddonManager/NetworkManager.py
+++ b/src/Mod/AddonManager/NetworkManager.py
@@ -1,5 +1,3 @@
-# -*- coding: utf-8 -*-
-
# ***************************************************************************
# * *
# * Copyright (c) 2022 FreeCAD Project Association *
diff --git a/src/Mod/AddonManager/Resources/AddonManager.qrc b/src/Mod/AddonManager/Resources/AddonManager.qrc
index 8a8520ed30..aca6f77601 100644
--- a/src/Mod/AddonManager/Resources/AddonManager.qrc
+++ b/src/Mod/AddonManager/Resources/AddonManager.qrc
@@ -62,8 +62,8 @@
icons/WebTools_workbench_icon.svg
icons/workfeature_workbench_icon.svg
icons/yaml-workspace_workbench_icon.svg
- icons/expanded_view.svg
icons/compact_view.svg
+ icons/expanded_view.svg
licenses/Apache-2.0.txt
licenses/BSD-2-Clause.txt
licenses/BSD-3-Clause.txt
diff --git a/src/Mod/AddonManager/TestAddonManagerApp.py b/src/Mod/AddonManager/TestAddonManagerApp.py
index f048466270..0ff6b021d5 100644
--- a/src/Mod/AddonManager/TestAddonManagerApp.py
+++ b/src/Mod/AddonManager/TestAddonManagerApp.py
@@ -1,28 +1,26 @@
-# -*- coding: utf-8 -*-
-
# ***************************************************************************
+# * *
# * Copyright (c) 2022 FreeCAD Project Association *
# * *
-# * This file is part of the FreeCAD CAx development system. *
+# * This file is part of FreeCAD. *
# * *
-# * This library 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 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. *
# * *
-# * This library 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 *
+# * 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 this library; if not, write to the Free Software *
-# * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA *
-# * 02110-1301 USA *
+# * License along with FreeCAD. If not, see *
+# * . *
# * *
# ***************************************************************************
-# Unit test for the Addon Manager module
+# Unit tests for the Addon Manager module
from AddonManagerTest.app.test_utilities import (
TestUtilities as AddonManagerTestUtilities,
)
@@ -35,9 +33,19 @@ from AddonManagerTest.app.test_macro import (
from AddonManagerTest.app.test_git import (
TestGit as AddonManagerTestGit,
)
+from AddonManagerTest.app.test_installer import (
+ TestAddonInstaller as AddonManagerTestAddonInstaller,
+ TestMacroInstaller as AddonManagerTestMacroInstaller,
+)
+from AddonManagerTest.app.test_dependency_installer import (
+ TestDependencyInstaller as AddonManagerTestDependencyInstaller,
+)
# dummy usage to get flake8 and lgtm quiet
False if AddonManagerTestUtilities.__name__ else True
False if AddonManagerTestAddon.__name__ else True
False if AddonManagerTestMacro.__name__ else True
False if AddonManagerTestGit.__name__ else True
+False if AddonManagerTestAddonInstaller.__name__ else True
+False if AddonManagerTestMacroInstaller.__name__ else True
+False if AddonManagerTestDependencyInstaller.__name__ else True
diff --git a/src/Mod/AddonManager/TestAddonManagerGui.py b/src/Mod/AddonManager/TestAddonManagerGui.py
index 7cb913b305..fe9e7ea016 100644
--- a/src/Mod/AddonManager/TestAddonManagerGui.py
+++ b/src/Mod/AddonManager/TestAddonManagerGui.py
@@ -1,5 +1,3 @@
-# -*- coding: utf-8 -*-
-
# ***************************************************************************
# * Copyright (c) 2022 FreeCAD Project Association *
# * *
@@ -31,13 +29,20 @@ from AddonManagerTest.gui.test_workers_utility import (
from AddonManagerTest.gui.test_workers_startup import (
TestWorkersStartup as AddonManagerTestWorkersStartup,
)
-from AddonManagerTest.gui.test_workers_installation import (
- TestWorkersInstallation as AddonManagerTestWorkersInstallation,
+from AddonManagerTest.gui.test_installer_gui import (
+ TestInstallerGui as AddonManagerTestInstallerGui,
+)
+from AddonManagerTest.gui.test_installer_gui import (
+ TestMacroInstallerGui as AddonManagerTestMacroInstallerGui,
+)
+from AddonManagerTest.gui.test_update_all_gui import (
+ TestUpdateAllGui as AddonManagerTestUpdateAllGui,
)
-
# dummy usage to get flake8 and lgtm quiet
False if AddonManagerTestGui.__name__ else True
False if AddonManagerTestWorkersUtility.__name__ else True
False if AddonManagerTestWorkersStartup.__name__ else True
-False if AddonManagerTestWorkersInstallation.__name__ else True
+False if AddonManagerTestInstallerGui.__name__ else True
+False if AddonManagerTestMacroInstallerGui.__name__ else True
+False if AddonManagerTestUpdateAllGui.__name__ else True
diff --git a/src/Mod/AddonManager/addonmanager_connection_checker.py b/src/Mod/AddonManager/addonmanager_connection_checker.py
index ead00add1e..8f85eb882d 100644
--- a/src/Mod/AddonManager/addonmanager_connection_checker.py
+++ b/src/Mod/AddonManager/addonmanager_connection_checker.py
@@ -94,7 +94,7 @@ class ConnectionCheckerGUI(QtCore.QObject):
None, translate("AddonsInstaller", "Connection failed"), message
)
else:
- #pylint: disable=line-too-long
+ # pylint: disable=line-too-long
QtWidgets.QMessageBox.critical(
None,
translate("AddonsInstaller", "Missing dependency"),
diff --git a/src/Mod/AddonManager/addonmanager_dependency_installer.py b/src/Mod/AddonManager/addonmanager_dependency_installer.py
new file mode 100644
index 0000000000..a78d200f7c
--- /dev/null
+++ b/src/Mod/AddonManager/addonmanager_dependency_installer.py
@@ -0,0 +1,199 @@
+# ***************************************************************************
+# * *
+# * 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 *
+# * . *
+# * *
+# ***************************************************************************
+
+"""Class to manage installation of sets of Python dependencies."""
+
+import os
+import subprocess
+from time import sleep
+from typing import List
+
+import FreeCAD
+
+from PySide import QtCore
+import addonmanager_utilities as utils
+from addonmanager_installer import AddonInstaller, MacroInstaller
+
+translate = FreeCAD.Qt.translate
+
+
+class DependencyInstaller(QtCore.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()
+
+ def __init__(
+ self,
+ addons: List[object],
+ python_requires: List[str],
+ python_optional: List[str],
+ location: os.PathLike = None,
+ ):
+ """Install the various types of dependencies that might be specified. If an optional
+ dependency fails this is non-fatal, but other failures are considered fatal. If location
+ is specified it overrides the FreeCAD user base directory setting: this is used mostly
+ for testing purposes and shouldn't be set by normal code in most circumstances."""
+ super().__init__()
+ self.addons = addons
+ self.python_requires = python_requires
+ self.python_optional = python_optional
+ self.location = location
+
+ def run(self):
+ """Normally not called directly, but rather connected to the worker thread's started
+ signal."""
+ if self._verify_pip():
+ if self.python_requires or self.python_optional:
+ if not QtCore.QThread.currentThread().isInterruptionRequested():
+ self._install_python_packages()
+ if not QtCore.QThread.currentThread().isInterruptionRequested():
+ self._install_addons()
+ self.finished.emit()
+
+ def _install_python_packages(self):
+ """Install required and optional Python dependencies using pip."""
+
+ if self.location:
+ vendor_path = os.path.join(self.location, "AdditionalPythonPackages")
+ else:
+ vendor_path = utils.get_pip_target_directory()
+ if not os.path.exists(vendor_path):
+ os.makedirs(vendor_path)
+
+ self._install_required(vendor_path)
+ self._install_optional(vendor_path)
+
+ def _verify_pip(self) -> bool:
+ """Ensure that pip is working -- returns True if it is, or False if not. Also emits the
+ no_pip signal if pip cannot execute."""
+ python_exe = self._get_python()
+ if not python_exe:
+ return False
+ try:
+ proc = self._run_pip(["--version"])
+ FreeCAD.Console.PrintMessage(proc.stdout + "\n")
+ except subprocess.CalledProcessError:
+ self.no_pip.emit(f"{python_exe} -m pip --version")
+ return False
+ return True
+
+ def _install_required(self, vendor_path: os.PathLike) -> bool:
+ """Install the required Python package dependencies. If any fail a failure signal is
+ emitted and the function exits without proceeding with any additional installs."""
+ for pymod in self.python_requires:
+ if QtCore.QThread.currentThread().isInterruptionRequested():
+ return
+ try:
+ proc = self._run_pip(
+ [
+ "install",
+ "--disable-pip-version-check",
+ "--target",
+ vendor_path,
+ pymod,
+ ]
+ )
+ FreeCAD.Console.PrintMessage(proc.stdout + "\n")
+ except subprocess.CalledProcessError as e:
+ FreeCAD.Console.PrintError(str(e) + "\n")
+ self.failure.emit(
+ translate(
+ "AddonsInstaller",
+ "Installation of Python package {} failed",
+ ).format(pymod),
+ str(e),
+ )
+ return
+
+ def _install_optional(self, vendor_path: os.PathLike):
+ """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():
+ return
+ try:
+ proc = self._run_pip(
+ [
+ "install",
+ "--disable-pip-version-check",
+ "--target",
+ vendor_path,
+ pymod,
+ ]
+ )
+ FreeCAD.Console.PrintMessage(proc.stdout + "\n")
+ except subprocess.CalledProcessError as e:
+ FreeCAD.Console.PrintError(
+ translate(
+ "AddonsInstaller", "Installation of optional package failed"
+ )
+ + ":\n"
+ + str(e)
+ + "\n"
+ )
+
+ def _run_pip(self, args):
+ python_exe = self._get_python()
+ final_args = [python_exe, "-m", "pip"]
+ final_args.extend(args)
+ return self._subprocess_wrapper(final_args)
+
+ def _subprocess_wrapper(self, args) -> object:
+ """Wrap subprocess call so test code can mock it."""
+ return utils.run_interruptable_subprocess(args)
+
+ def _get_python(self) -> str:
+ """Wrap Python access so test code can mock it."""
+ python_exe = utils.get_python_exe()
+ if not python_exe:
+ self.no_python_exe.emit()
+ return python_exe
+
+ def _install_addons(self):
+ for addon in self.addons:
+ if QtCore.QThread.currentThread().isInterruptionRequested():
+ return
+ FreeCAD.Console.PrintMessage(
+ translate(
+ "AddonsInstaller", "Installing required dependency {}"
+ ).format(addon.name)
+ + "\n"
+ )
+ if addon.macro is None:
+ installer = AddonInstaller(addon)
+ else:
+ installer = MacroInstaller(addon)
+ result = (
+ installer.run()
+ ) # Run in this thread, which should be off the GUI thread
+ if not result:
+ self.failure.emit(
+ translate(
+ "AddonsInstaller", "Installation of Addon {} failed"
+ ).format(addon.name),
+ "",
+ )
+ return
diff --git a/src/Mod/AddonManager/addonmanager_devmode.py b/src/Mod/AddonManager/addonmanager_devmode.py
index 5bdf0fff7f..ba552a3a69 100644
--- a/src/Mod/AddonManager/addonmanager_devmode.py
+++ b/src/Mod/AddonManager/addonmanager_devmode.py
@@ -442,7 +442,7 @@ class DeveloperMode:
if self.dialog.minPythonLineEdit.text():
self.metadata.PythonMin = self.dialog.minPythonLineEdit.text()
else:
- self.metadata.PythonMin = "0.0.0" # Code for "unset"
+ self.metadata.PythonMin = "0.0.0" # Code for "unset"
# Content, people, and licenses should already be sync'ed
@@ -598,7 +598,7 @@ class DeveloperMode:
)
+ "...\n"
)
- #pylint: disable=import-outside-toplevel
+ # pylint: disable=import-outside-toplevel
import vermin
required_minor_version = 0
@@ -640,10 +640,10 @@ class DeveloperMode:
def _ensure_vermin_loaded(self) -> bool:
try:
- #pylint: disable=import-outside-toplevel,unused-import
+ # pylint: disable=import-outside-toplevel,unused-import
import vermin
except ImportError:
- #pylint: disable=line-too-long
+ # pylint: disable=line-too-long
response = QMessageBox.question(
self.dialog,
translate("AddonsInstaller", "Install Vermin?"),
@@ -695,7 +695,7 @@ class DeveloperMode:
)
return False
try:
- #pylint: disable=import-outside-toplevel
+ # pylint: disable=import-outside-toplevel
import vermin
except ImportError:
response = QMessageBox.critical(
diff --git a/src/Mod/AddonManager/addonmanager_devmode_add_content.py b/src/Mod/AddonManager/addonmanager_devmode_add_content.py
index 1711131ba2..29f05c7ec8 100644
--- a/src/Mod/AddonManager/addonmanager_devmode_add_content.py
+++ b/src/Mod/AddonManager/addonmanager_devmode_add_content.py
@@ -364,7 +364,7 @@ class AddContent:
if not self.metadata:
self.metadata = FreeCAD.Metadata()
dlg = EditDependencies()
- dlg.exec(self.metadata) # Modifies metadata directly
+ dlg.exec(self.metadata) # Modifies metadata directly
class EditTags:
@@ -536,7 +536,6 @@ class EditDependency:
self.dialog.layout().setSizeConstraint(QLayout.SetFixedSize)
-
def exec(
self, dep_type="", dep_name="", dep_optional=False
) -> Tuple[str, str, bool]:
diff --git a/src/Mod/AddonManager/addonmanager_devmode_license_selector.py b/src/Mod/AddonManager/addonmanager_devmode_license_selector.py
index 3082cbe038..4a9c175fea 100644
--- a/src/Mod/AddonManager/addonmanager_devmode_license_selector.py
+++ b/src/Mod/AddonManager/addonmanager_devmode_license_selector.py
@@ -38,6 +38,7 @@ try:
QRegularExpressionValidator,
)
from PySide.QtCore import QRegularExpression
+
RegexWrapper = QRegularExpression
RegexValidatorWrapper = QRegularExpressionValidator
except ImportError:
@@ -45,6 +46,7 @@ except ImportError:
QRegExpValidator,
)
from PySide.QtCore import QRegExp
+
RegexWrapper = QRegExp
RegexValidatorWrapper = QRegExpValidator
diff --git a/src/Mod/AddonManager/addonmanager_devmode_licenses_table.py b/src/Mod/AddonManager/addonmanager_devmode_licenses_table.py
index 5421ebac15..9b6c73e43a 100644
--- a/src/Mod/AddonManager/addonmanager_devmode_licenses_table.py
+++ b/src/Mod/AddonManager/addonmanager_devmode_licenses_table.py
@@ -34,7 +34,8 @@ from addonmanager_devmode_license_selector import LicenseSelector
translate = FreeCAD.Qt.translate
-#pylint: disable=too-few-public-methods
+# pylint: disable=too-few-public-methods
+
class LicensesTable:
"""A QTableWidget and associated buttons for managing the list of authors and maintainers."""
diff --git a/src/Mod/AddonManager/addonmanager_devmode_people_table.py b/src/Mod/AddonManager/addonmanager_devmode_people_table.py
index f1b5a4e0c1..ed3fccf68b 100644
--- a/src/Mod/AddonManager/addonmanager_devmode_people_table.py
+++ b/src/Mod/AddonManager/addonmanager_devmode_people_table.py
@@ -35,7 +35,7 @@ from addonmanager_devmode_person_editor import PersonEditor
translate = FreeCAD.Qt.translate
-#pylint: disable=too-few-public-methods
+# pylint: disable=too-few-public-methods
class PeopleTable:
diff --git a/src/Mod/AddonManager/addonmanager_devmode_predictor.py b/src/Mod/AddonManager/addonmanager_devmode_predictor.py
index 0bbb6f048e..a177b72ff2 100644
--- a/src/Mod/AddonManager/addonmanager_devmode_predictor.py
+++ b/src/Mod/AddonManager/addonmanager_devmode_predictor.py
@@ -37,7 +37,7 @@ from addonmanager_utilities import get_readme_url
translate = FreeCAD.Qt.translate
-#pylint: disable=too-few-public-methods
+# pylint: disable=too-few-public-methods
class AddonSlice:
@@ -89,12 +89,12 @@ class Predictor:
# is common for there to be multiple entries representing the same human being,
# so a passing attempt is made to reconcile:
filtered_committers = {}
- for key,committer in committers.items():
+ for key, committer in committers.items():
if "github" in key.lower():
# Robotic merge commit (or other similar), ignore
continue
# Does any other committer share any of these emails?
- for other_key,other_committer in committers.items():
+ for other_key, other_committer in committers.items():
if other_key == key:
continue
for other_email in other_committer["email"]:
diff --git a/src/Mod/AddonManager/addonmanager_devmode_validators.py b/src/Mod/AddonManager/addonmanager_devmode_validators.py
index 29a3d15eb4..b4417a1e5e 100644
--- a/src/Mod/AddonManager/addonmanager_devmode_validators.py
+++ b/src/Mod/AddonManager/addonmanager_devmode_validators.py
@@ -35,6 +35,7 @@ try:
QRegularExpressionValidator,
)
from PySide.QtCore import QRegularExpression
+
RegexWrapper = QRegularExpression
RegexValidatorWrapper = QRegularExpressionValidator
except ImportError:
@@ -42,11 +43,13 @@ except ImportError:
QRegExpValidator,
)
from PySide.QtCore import QRegExp
+
RegexWrapper = QRegExp
RegexValidatorWrapper = QRegExpValidator
-#pylint: disable=too-few-public-methods
+# pylint: disable=too-few-public-methods
+
class NameValidator(QValidator):
"""Simple validator to exclude characters that are not valid in filenames."""
@@ -73,7 +76,7 @@ class PythonIdentifierValidator(QValidator):
"""Validates whether input is a valid Python identifier."""
def validate(self, value: str, _: int):
- """ The function that does the validation. """
+ """The function that does the validation."""
if not value:
return QValidator.Intermediate
@@ -130,7 +133,6 @@ class CalVerValidator(RegexValidatorWrapper):
else:
self.setRegExp(CalVerValidator.calver_re)
-
@classmethod
def check(cls, value: str) -> bool:
"""Returns true if value validates, and false if not"""
diff --git a/src/Mod/AddonManager/addonmanager_firstrun.py b/src/Mod/AddonManager/addonmanager_firstrun.py
index 21d3ad5b6f..6229c49d3f 100644
--- a/src/Mod/AddonManager/addonmanager_firstrun.py
+++ b/src/Mod/AddonManager/addonmanager_firstrun.py
@@ -31,10 +31,11 @@ import FreeCADGui
import addonmanager_utilities as utils
-#pylint: disable=too-few-public-methods
+# pylint: disable=too-few-public-methods
+
class FirstRunDialog:
- """ Manage the display of the Addon Manager's first-run dialog, setting up some user
+ """Manage the display of the Addon Manager's first-run dialog, setting up some user
preferences and making sure they are aware that this connects to the internet, downloads
data, and possiibly installs things that run code not affiliated with FreeCAD itself."""
@@ -82,7 +83,9 @@ class FirstRunDialog:
if warning_dialog.exec() == QtWidgets.QDialog.Accepted:
self.readWarning = True
self.pref.SetBool("readWarning2022", True)
- self.pref.SetBool("AutoCheck", warning_dialog.checkBoxAutoCheck.isChecked())
+ self.pref.SetBool(
+ "AutoCheck", warning_dialog.checkBoxAutoCheck.isChecked()
+ )
self.pref.SetBool(
"DownloadMacros",
warning_dialog.checkBoxDownloadMacroMetadata.isChecked(),
diff --git a/src/Mod/AddonManager/addonmanager_git.py b/src/Mod/AddonManager/addonmanager_git.py
index 1ca7864542..053dc37fc0 100644
--- a/src/Mod/AddonManager/addonmanager_git.py
+++ b/src/Mod/AddonManager/addonmanager_git.py
@@ -32,6 +32,10 @@ from typing import List
import time
import FreeCAD
+from PySide import QtCore # Needed to detect thread interruption
+
+import addonmanager_utilities as utils
+
translate = FreeCAD.Qt.translate
@@ -99,8 +103,8 @@ class GitManager:
"""Fetches and pulls the local_path from its remote"""
old_dir = os.getcwd()
os.chdir(local_path)
- self._synchronous_call_git(["fetch"])
try:
+ self._synchronous_call_git(["fetch"])
self._synchronous_call_git(["pull"])
self._synchronous_call_git(["submodule", "update", "--init", "--recursive"])
except GitFailed as e:
@@ -355,30 +359,14 @@ class GitManager:
"""Calls git and returns its output."""
final_args = [self.git_exe]
final_args.extend(args)
- try:
- if os.name == "nt":
- proc = subprocess.run(
- final_args,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- check=True,
- shell=True, # On Windows this will prevent all the pop-up consoles
- )
- else:
- proc = subprocess.run(
- final_args,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- check=True,
- )
- except subprocess.CalledProcessError as e:
- raise GitFailed(str(e)) from e
- if proc.returncode != 0:
+ try:
+ proc = utils.run_interruptable_subprocess(final_args)
+ except subprocess.CalledProcessError as e:
raise GitFailed(
- f"Git returned a non-zero exit status: {proc.returncode}\n"
+ f"Git returned a non-zero exit status: {e.returncode}\n"
+ f"Called with: {' '.join(final_args)}\n\n"
- + f"Returned stderr:\n{proc.stderr.decode()}"
+ + f"Returned stderr:\n{e.stderr}"
)
- return proc.stdout.decode()
+ return proc.stdout
diff --git a/src/Mod/AddonManager/addonmanager_installer.py b/src/Mod/AddonManager/addonmanager_installer.py
new file mode 100644
index 0000000000..d53c7b7f8a
--- /dev/null
+++ b/src/Mod/AddonManager/addonmanager_installer.py
@@ -0,0 +1,512 @@
+# ***************************************************************************
+# * *
+# * 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 installation: intended as a stable API, safe for external
+code to call and to rely upon existing. See classes AddonInstaller and MacroInstaller for details.
+"""
+
+from datetime import datetime, timezone
+from enum import IntEnum, auto
+import os
+import shutil
+from typing import List, Optional
+import tempfile
+from urllib.parse import urlparse
+import zipfile
+
+import FreeCAD
+
+from PySide import QtCore
+
+from Addon import Addon
+import addonmanager_utilities as utils
+from addonmanager_git import initialize_git, GitFailed
+
+if FreeCAD.GuiUp:
+ import NetworkManager # Requires an event loop
+
+translate = FreeCAD.Qt.translate
+
+# pylint: disable=too-few-public-methods
+
+
+class InstallationMethod(IntEnum):
+ """For packages installed from a git repository, in most cases it is possible to either use git
+ or to download a zip archive of the addon. For a local repository, a direct copy may be used
+ instead. If "ANY" is given, the the internal code decides which to use."""
+
+ GIT = auto()
+ COPY = auto()
+ ZIP = auto()
+ ANY = auto()
+
+
+class AddonInstaller(QtCore.QObject):
+ """The core, non-GUI installer class. Usually instantiated and moved to its own thread,
+ otherwise it will block the GUI (if the GUI is running). In all cases in this class, the
+ generic Python 'object' is intended to be an Addon-like object that provides, at a minimum,
+ a 'name', 'url', and 'branch' attribute. The Addon manager uses the Addon class for this
+ purpose, but external code may use any other class that meets those criteria.
+
+ Recommended Usage (when running with the GUI up, so you don't block the GUI thread):
+
+ import functools # With the rest of your imports, for functools.partial
+
+ ...
+
+ addon_to_install = MyAddon() # Some class with name, url, and branch attributes
+
+ self.worker_thread = QtCore.QThread()
+ self.installer = AddonInstaller(addon_to_install, list_of_known_addons)
+ self.installer.moveToThread(self.worker_thread)
+ self.installer.success.connect(self.installation_succeeded)
+ self.installer.failure.connect(self.installation_failed)
+ self.installer.finished.connect(self.worker_thread.quit)
+ self.worker_thread.started.connect(self.installer.run)
+ self.worker_thread.start() # Returns immediately
+
+ # On success, the connections above result in self.installation_succeeded being called, and
+ # on failure, self.installation_failed is called.
+
+
+ Recommended non-GUI usage (blocks until complete):
+
+ installer = AddonInstaller(list_of_known_addons)
+ addon_to_install = MyAddon() # Some class with name, url, and branch attributes
+ installer.install(addon_to_install)
+
+ """
+
+ # Signal: progress_update
+ # In GUI mode this signal is emitted periodically during long downloads. The two integers are
+ # the number of bytes downloaded, and the number of bytes expected, respectively. Note that the
+ # number of bytes expected might be set to 0 to indicate an unknown download size.
+ progress_update = QtCore.Signal(int, int)
+
+ # Signals: success and failure
+ # Emitted when the installation process is complete. The object emitted is the object that the
+ # installation was requested for (usually of class Addon, but any class that provides a name,
+ # url, and branch attribute can be used).
+ 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()
+
+ allowed_packages = set()
+
+ def __init__(
+ self, addon: object, addons: List[object] = None, allow_list: List[str] = None
+ ):
+ """Initialize the installer with an optional list of addons. If provided, then installation
+ by name is supported, as long as the objects in the list contain a "name" and "url"
+ property. In most use cases it is expected that addons is a List of Addon objects, but that
+ is not a requirement. An optional allow_list lets calling code override the allowed Python
+ packages list with a custom list. It is mostly for unit testing purposes."""
+ super().__init__()
+ self.addon_to_install = addon
+ self.addons = addons if addons is not None else []
+
+ self.git_manager = initialize_git()
+
+ if allow_list is not None:
+ AddonInstaller.allowed_packages = set(
+ allow_list if allow_list is not None else []
+ )
+ elif not AddonInstaller.allowed_packages:
+ AddonInstaller._load_local_allowed_packages_list()
+ AddonInstaller._update_allowed_packages_list()
+
+ basedir = FreeCAD.getUserAppDataDir()
+ self.installation_path = os.path.join(basedir, "Mod")
+ self.macro_installation_path = FreeCAD.getUserMacroDir(True)
+ self.zip_download_index = None
+
+ def run(self, install_method: InstallationMethod = InstallationMethod.ANY) -> bool:
+ """Install an addon. Returns True if the addon was installed, or False if not. Emits
+ either success or failure prior to returning."""
+ try:
+ addon_url = self.addon_to_install.url.replace(os.path.sep, "/")
+ method_to_use = self._determine_install_method(addon_url, install_method)
+ success = False
+ if method_to_use == InstallationMethod.ZIP:
+ success = self._install_by_zip()
+ elif method_to_use == InstallationMethod.GIT:
+ success = self._install_by_git()
+ elif method_to_use == InstallationMethod.COPY:
+ success = self._install_by_copy()
+ except utils.ProcessInterrupted:
+ pass
+ self.finished.emit()
+ return success
+
+ @classmethod
+ def _load_local_allowed_packages_list(cls) -> None:
+ """Read in the local allow-list, in case the remote one is unavailable."""
+ cls.allowed_packages.clear()
+ allow_file = os.path.join(
+ os.path.dirname(__file__), "ALLOWED_PYTHON_PACKAGES.txt"
+ )
+ if os.path.exists(allow_file):
+ with open(allow_file, "r", encoding="utf8") as f:
+ lines = f.readlines()
+ for line in lines:
+ if line and len(line) > 0 and line[0] != "#":
+ cls.allowed_packages.add(line.strip().lower())
+
+ @classmethod
+ def _update_allowed_packages_list(cls) -> None:
+ """Get a new remote copy of the allowed packages list from GitHub."""
+ FreeCAD.Console.PrintLog(
+ "Attempting to fetch remote copy of ALLOWED_PYTHON_PACKAGES.txt...\n"
+ )
+ p = utils.blocking_get(
+ "https://raw.githubusercontent.com/"
+ "FreeCAD/FreeCAD-addons/master/ALLOWED_PYTHON_PACKAGES.txt"
+ )
+ if p:
+ FreeCAD.Console.PrintLog(
+ "Overriding local ALLOWED_PYTHON_PACKAGES.txt with newer remote version\n"
+ )
+ p = p.data().decode("utf8")
+ lines = p.split("\n")
+ cls.allowed_packages.clear() # Unset the locally-defined list
+ for line in lines:
+ if line and len(line) > 0 and line[0] != "#":
+ cls.allowed_packages.add(line.strip().lower())
+ else:
+ FreeCAD.Console.PrintLog(
+ "Could not fetch remote ALLOWED_PYTHON_PACKAGES.txt, using local copy\n"
+ )
+
+ def _determine_install_method(
+ self, addon_url: str, install_method: InstallationMethod
+ ) -> Optional[InstallationMethod]:
+ """Given a URL and preferred installation method, determine the actual installation method
+ to use. Will return either None, if installation is not possible for the given url and
+ method, or a specific concrete method (GIT, ZIP, or COPY) based on the inputs."""
+
+ # If we don't have access to git, and that is the method selected, return early
+ if not self.git_manager and install_method == InstallationMethod.GIT:
+ return None
+
+ parse_result = urlparse(addon_url)
+ is_git_only = parse_result.scheme in ["git", "ssh", "rsync"]
+ is_remote = parse_result.scheme in ["http", "https", "git", "ssh", "rsync"]
+ is_zipfile = parse_result.path.lower().endswith(".zip")
+
+ # Can't use "copy" for a remote URL
+ if is_remote and install_method == InstallationMethod.COPY:
+ return None
+
+ if is_git_only:
+ if (
+ install_method in (InstallationMethod.GIT, InstallationMethod.ANY)
+ ) and self.git_manager:
+ # If it's a git-only URL, only git can be used for the installation
+ return InstallationMethod.GIT
+ # So if it's not a git installation, return None
+ return None
+
+ if is_zipfile:
+ if install_method == InstallationMethod.GIT:
+ # Can't use git on zip files
+ return None
+ return InstallationMethod.ZIP # Copy just becomes zip
+ if not is_remote and install_method == InstallationMethod.ZIP:
+ return None # Can't use zip on local paths that aren't zip files
+
+ # Whatever scheme was passed in appears to be reasonable, return it
+ if install_method != InstallationMethod.ANY:
+ return install_method
+
+ # Prefer to copy, if it's local:
+ if not is_remote:
+ return InstallationMethod.COPY
+
+ # Prefer git if we have git
+ if self.git_manager:
+ return InstallationMethod.GIT
+
+ # Fall back to ZIP in other cases, though this relies on remote hosts falling
+ # into one of a few particular patterns
+ return InstallationMethod.ZIP
+
+ def _install_by_copy(self) -> bool:
+ """Installs the specified url by copying directly from it into the installation
+ location. addon_url must be copyable using filesystem operations. Any existing files at
+ that location are overwritten."""
+ addon_url = self.addon_to_install.url
+ if addon_url.startswith("file://"):
+ addon_url = addon_url[len("file://") :] # Strip off the file:// part
+ name = self.addon_to_install.name
+ shutil.copytree(
+ addon_url, os.path.join(self.installation_path, name), dirs_exist_ok=True
+ )
+ self._finalize_successful_installation()
+ return True
+
+ def _install_by_git(self) -> bool:
+ """Installs the specified url by using git to clone from it. The URL can be local or remote,
+ but must represent a git repository, and the url must be in a format that git can handle
+ (git, ssh, rsync, file, or a bare filesystem path)."""
+ install_path = os.path.join(self.installation_path, self.addon_to_install.name)
+ try:
+ if os.path.isdir(install_path):
+ self.git_manager.update(install_path)
+ else:
+ self.git_manager.clone(self.addon_to_install.url, install_path)
+ self.git_manager.checkout(install_path, self.addon_to_install.branch)
+ except GitFailed as e:
+ self.failure.emit(self.addon_to_install, str(e))
+ return False
+ self._finalize_successful_installation()
+ return True
+
+ def _install_by_zip(self) -> bool:
+ """Installs the specified url by downloading the file (if it is remote) and unzipping it
+ into the appropriate installation location. If the GUI is running the download is
+ asynchronous, and issues periodic updates about how much data has been downloaded."""
+ if self.addon_to_install.url.endswith(".zip"):
+ zip_url = self.addon_to_install.url
+ else:
+ zip_url = utils.get_zip_url(self.addon_to_install)
+
+ parse_result = urlparse(zip_url)
+ is_remote = parse_result.scheme in ["http", "https"]
+
+ if is_remote:
+ if FreeCAD.GuiUp:
+ NetworkManager.AM_NETWORK_MANAGER.progress_made.connect(
+ self._update_zip_status
+ )
+ NetworkManager.AM_NETWORK_MANAGER.progress_complete.connect(
+ self._finish_zip
+ )
+ self.zip_download_index = (
+ NetworkManager.AM_NETWORK_MANAGER.submit_monitored_get(zip_url)
+ )
+ else:
+ zip_data = utils.blocking_get(zip_url)
+ with tempfile.NamedTemporaryFile(delete=False) as f:
+ tempfile_name = f.name
+ f.write(zip_data)
+ self._finalize_zip_installation(tempfile_name)
+ else:
+ self._finalize_zip_installation(zip_url)
+ return True
+
+ def _update_zip_status(self, index: int, bytes_read: int, data_size: int):
+ """Called periodically when downloading a zip file, emits a signal to display the
+ download progress."""
+ if index == self.zip_download_index:
+ self.progress_update.emit(bytes_read, data_size)
+
+ def _finish_zip(self, _: int, response_code: int, filename: os.PathLike):
+ """Once the zip download is finished, unzip it into the correct location. Only called if
+ the GUI is up, and the NetworkManager was responsible for the download. Do not call
+ directly."""
+ if response_code != 200:
+ self.failure.emit(
+ self.addon_to_install,
+ translate(
+ "AddonsInstaller", "Received {} response code from server"
+ ).format(response_code),
+ )
+ return
+ QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents)
+
+ self._finalize_zip_installation(filename)
+
+ def _finalize_zip_installation(self, filename: os.PathLike):
+ """Given a path to a zipfile, extract that file and put its contents in the correct
+ location. Has special handling for GitHub's zip structure, which places the data in a
+ subdirectory of the main directory."""
+
+ subdirectory = ""
+ destination = os.path.join(self.installation_path, self.addon_to_install.name)
+ with zipfile.ZipFile(filename, "r") as zfile:
+ zfile.extractall(destination)
+
+ # GitHub (and possibly other hosts) put all files in the zip into a subdirectory named
+ # after the branch. If that is the setup that we just extracted, move all files out of
+ # that subdirectory.
+ subdirectories = os.listdir(destination)
+ if (
+ len(subdirectories) == 1
+ and subdirectories[0] == self.addon_to_install.branch
+ ):
+ subdirectory = subdirectories[0]
+
+ if subdirectory:
+ for extracted_filename in os.listdir(
+ os.path.join(destination, subdirectory)
+ ):
+ shutil.move(
+ os.path.join(destination, subdirectory, extracted_filename),
+ os.path.join(destination, extracted_filename),
+ )
+ os.rmdir(os.path.join(destination, subdirectory))
+ self._finalize_successful_installation()
+
+ def _finalize_successful_installation(self):
+ """Perform any necessary additional steps after installing the addon."""
+ self._update_metadata()
+ self._install_macros()
+ self.success.emit(self.addon_to_install)
+
+ def _update_metadata(self):
+ """Loads the package metadata from the Addon's downloaded package.xml file."""
+ package_xml = os.path.join(
+ self.installation_path, self.addon_to_install.name, "package.xml"
+ )
+
+ if hasattr(self.addon_to_install, "metadata") and os.path.isfile(package_xml):
+ self.addon_to_install.load_metadata_file(package_xml)
+ self.addon_to_install.installed_version = (
+ self.addon_to_install.metadata.Version
+ )
+ self.addon_to_install.updated_timestamp = os.path.getmtime(package_xml)
+
+ def _install_macros(self):
+ """For any workbenches, copy FCMacro files into the macro directory. Exclude packages that
+ have preference packs, otherwise we will litter the macro directory with the pre and post
+ scripts."""
+ if (
+ isinstance(self.addon_to_install, Addon)
+ and self.addon_to_install.contains_preference_pack()
+ ):
+ return
+
+ if not os.path.exists(self.macro_installation_path):
+ os.makedirs(self.macro_installation_path)
+
+ installed_macro_files = []
+ for root, _, files in os.walk(
+ os.path.join(self.installation_path, self.addon_to_install.name)
+ ):
+ for f in files:
+ if f.lower().endswith(".fcmacro"):
+ src = os.path.join(root, f)
+ dst = os.path.join(self.macro_installation_path, f)
+ shutil.copy2(src, dst)
+ installed_macro_files.append(dst)
+ if installed_macro_files:
+ with open(
+ os.path.join(
+ self.installation_path,
+ self.addon_to_install.name,
+ "AM_INSTALLATION_DIGEST.txt",
+ ),
+ "a",
+ encoding="utf-8",
+ ) as f:
+ now = datetime.now(timezone.utc)
+ f.write(
+ "# The following files were created outside this installation "
+ f"path during the installation of this Addon on {now}:\n"
+ )
+ for fcmacro_file in installed_macro_files:
+ f.write(fcmacro_file + "\n")
+
+ @classmethod
+ def _validate_object(cls, addon: object):
+ """Make sure the object has the necessary attributes (name, url, and branch) to be
+ installed."""
+
+ if (
+ not hasattr(addon, "name")
+ or not hasattr(addon, "url")
+ or not hasattr(addon, "branch")
+ ):
+ raise RuntimeError(
+ "Provided object does not provide a name, url, and/or branch attribute"
+ )
+
+
+class MacroInstaller(QtCore.QObject):
+ """Install a macro."""
+
+ # Signals: success and failure
+ # Emitted when the installation process is complete. The object emitted is the object that the
+ # installation was requested for (usually of class Addon, but any class that provides a macro
+ # can be used).
+ success = QtCore.Signal(object)
+ failure = QtCore.Signal(object)
+
+ # 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):
+ """The provided addon object must have an attribute called "macro", and that attribute must
+ itself provide a callable "install" method that takes a single string, the path to the
+ installation location."""
+ super().__init__()
+ self._validate_object(addon)
+ self.addon_to_install = addon
+ self.installation_path = FreeCAD.getUserMacroDir(True)
+
+ def run(self) -> bool:
+ """Install a macro. Returns True if the macro was installed, or False if not. Emits
+ either success or failure prior to returning."""
+
+ # To try to ensure atomicity, perform the installation into a temp directory
+ macro = self.addon_to_install.macro
+ with tempfile.TemporaryDirectory() as temp_dir:
+ temp_install_succeeded, error_list = macro.install(temp_dir)
+ if not temp_install_succeeded:
+ FreeCAD.Console.PrintError(
+ translate("AddonsInstaller", "Failed to install macro {}").format(
+ macro.name
+ )
+ + "\n"
+ )
+ for e in error_list:
+ FreeCAD.Console.PrintError(e + "\n")
+ self.failure.emit(self.addon_to_install, "\n".join(error_list))
+ self.finished.emit()
+ return False
+
+ # If it succeeded, move all of the files to the macro install location
+ for item in os.listdir(temp_dir):
+ src = os.path.join(temp_dir, item)
+ dst = os.path.join(self.installation_path, item)
+ shutil.move(src, dst)
+ self.success.emit(self.addon_to_install)
+ self.finished.emit()
+ return True
+
+ @classmethod
+ def _validate_object(cls, addon: object):
+ """Make sure this object provides an attribute called "macro" with a method called
+ "install" """
+ if (
+ not hasattr(addon, "macro")
+ or addon.macro is None
+ or not hasattr(addon.macro, "install")
+ or not callable(addon.macro.install)
+ ):
+ raise RuntimeError(
+ "Provided object does not provide a macro with an install method"
+ )
diff --git a/src/Mod/AddonManager/addonmanager_installer_gui.py b/src/Mod/AddonManager/addonmanager_installer_gui.py
new file mode 100644
index 0000000000..f73fd3b17a
--- /dev/null
+++ b/src/Mod/AddonManager/addonmanager_installer_gui.py
@@ -0,0 +1,831 @@
+# ***************************************************************************
+# * *
+# * 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 *
+# * . *
+# * *
+# ***************************************************************************
+
+"""Classes to manage the GUI presentation of installing an Addon (e.g. the sequence of dialog boxes
+that do dependency resolution, error handling, etc.). See AddonInstallerGUI and MacroInstallerGUI
+classes for details."""
+
+import os
+import sys
+from typing import List
+
+import FreeCAD
+import FreeCADGui
+from PySide import QtCore, QtWidgets
+
+from addonmanager_installer import AddonInstaller, MacroInstaller
+from addonmanager_dependency_installer import DependencyInstaller
+import addonmanager_utilities as utils
+from Addon import MissingDependencies
+
+translate = FreeCAD.Qt.translate
+
+# pylint: disable=c-extension-no-member,too-few-public-methods,too-many-instance-attributes
+
+
+class AddonInstallerGUI(QtCore.QObject):
+ """GUI functions (sequence of dialog boxes) for installing an addon interactively. The actual
+ installation is handled by the AddonInstaller class running in a separate QThread. An instance
+ of this AddonInstallerGUI class should NOT be run in a separate thread, but on the main GUI
+ thread. All dialogs are modal."""
+
+ # External classes are expected to "set and forget" this class, but in the event that some
+ # action must be taken if the addon is actually installed, this signal is provided. Note that
+ # this class already provides a "Successful installation" dialog, so external code need not
+ # do so.
+ success = QtCore.Signal(object)
+
+ # Emitted once all work has been completed, regardless of success or failure
+ finished = QtCore.Signal()
+
+ def __init__(self, addon: object, addons: List[object] = None):
+ super().__init__()
+ self.addon_to_install = addon
+ self.addons = [] if addons is None else addons
+ self.installer = AddonInstaller(addon, addons)
+ self.dependency_installer = None
+ self.install_worker = None
+ self.dependency_dialog = None
+ self.dependency_worker_thread = None
+ self.dependency_installation_dialog = None
+ self.installing_dialog = None
+ self.worker_thread = None
+
+ # Set up the installer connections
+ self.installer.success.connect(self._installation_succeeded)
+ self.installer.failure.connect(self._installation_failed)
+
+ def __del__(self):
+ if self.worker_thread and hasattr(self.worker_thread, "quit"):
+ self.worker_thread.quit()
+ self.worker_thread.wait(500)
+ if self.worker_thread.isRunning():
+ self.worker_thread.terminate()
+
+ def run(self):
+ """Instructs this class to begin displaying the necessary dialogs to guide a user through
+ an Addon installation sequence. All dialogs are modal."""
+
+ # Dependency check
+ deps = MissingDependencies(self.addon_to_install, self.addons)
+
+ # Python interpreter version check
+ stop_installation = self._check_python_version(deps)
+ if stop_installation:
+ self.finished.emit()
+ return
+
+ # Required Python
+ if hasattr(deps, "python_requires") and deps.python_requires:
+ # Disallowed packages:
+ stop_installation = self._handle_disallowed_python(deps.python_requires)
+ if stop_installation:
+ self.finished.emit()
+ return
+ # Allowed but uninstalled is handled below
+
+ # Remove any disallowed packages from the optional list
+ if hasattr(deps, "python_optional") and deps.python_optional:
+ self._clean_up_optional(deps)
+
+ # Missing FreeCAD workbenches
+ if hasattr(deps, "wbs") and deps.wbs:
+ stop_installation = self._report_missing_workbenches(deps.wbs)
+ if stop_installation:
+ self.finished.emit()
+ return
+
+ # If we have any missing dependencies, display a dialog to the user asking if they want to
+ # install them.
+ if deps.external_addons or deps.python_requires or deps.python_optional:
+ # Recoverable: ask the user if they want to install the missing deps, do so, then
+ # proceed with the installation
+ self._resolve_dependencies_then_install(deps)
+ else:
+ # No missing deps, just install
+ self.install()
+
+ def _handle_disallowed_python(self, python_requires: List[str]) -> bool:
+ """Determine if we are missing any required Python packages that are not in the allowed
+ packages list. If so, display a message to the user, and return True. Otherwise return
+ False."""
+
+ bad_packages = []
+ for dep in python_requires:
+ if dep.lower() not in AddonInstaller.allowed_packages:
+ bad_packages.append(dep)
+
+ for dep in bad_packages:
+ python_requires.remove(dep)
+
+ if bad_packages:
+ # pylint: disable=line-too-long
+ message = (
+ ""
+ + translate(
+ "AddonsInstaller",
+ "This addon requires Python packages that are not installed, and cannot be installed automatically. To use this workbench you must install the following Python packages manually:",
+ )
+ + "
"
+ )
+ if len(bad_packages) < 15:
+ for dep in bad_packages:
+ message += f"- {dep}
"
+ else:
+ message += (
+ "- ("
+ + translate("AddonsInstaller", "Too many to list")
+ + ")
"
+ )
+ message += "
"
+ message += "To ignore this error and install anyway, press OK."
+ r = QtWidgets.QMessageBox.critical(
+ None,
+ translate("AddonsInstaller", "Missing Requirement"),
+ message,
+ QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel,
+ )
+ FreeCAD.Console.PrintMessage(
+ translate(
+ "AddonsInstaller",
+ "The following Python packages are allowed to be automatically installed",
+ )
+ + ":\n"
+ )
+ for package in self.installer.allowed_packages:
+ FreeCAD.Console.PrintMessage(f" * {package}\n")
+
+ if r == QtWidgets.QMessageBox.Ok:
+ # Force the installation to proceed
+ return False
+ return True
+ return False
+
+ def _report_missing_workbenches(self, wbs) -> bool:
+ """If there are missing workbenches, display a dialog informing the user. Returns True to
+ stop the installation, or False to proceed."""
+ addon_name = self.addon_to_install.name
+ if len(wbs) == 1:
+ name = wbs[0]
+ message = translate(
+ "AddonsInstaller",
+ "Addon '{}' requires '{}', which is not available in your copy of FreeCAD.",
+ ).format(addon_name, name)
+ else:
+ # pylint: disable=line-too-long
+ message = (
+ ""
+ + translate(
+ "AddonsInstaller",
+ "Addon '{}' requires the following workbenches, which are not available in your copy of FreeCAD:",
+ ).format(addon_name)
+ + "
"
+ )
+ for wb in wbs:
+ message += "- " + wb + "
"
+ message += "
"
+ message += translate("AddonsInstaller", "Press OK to install anyway.")
+ r = QtWidgets.QMessageBox.critical(
+ None,
+ translate("AddonsInstaller", "Missing Requirement"),
+ message,
+ QtWidgets.QMessageBox.Ok | QtWidgets.QMessageBox.Cancel,
+ )
+ return r == QtWidgets.QMessageBox.Cancel
+
+ def _resolve_dependencies_then_install(self, missing) -> None:
+ """Ask the user how they want to handle dependencies, do that, then install."""
+ self.dependency_dialog = FreeCADGui.PySideUic.loadUi(
+ os.path.join(os.path.dirname(__file__), "dependency_resolution_dialog.ui")
+ )
+ self.dependency_dialog.setWindowFlag(QtCore.Qt.WindowStaysOnTopHint, True)
+
+ for addon in missing.external_addons:
+ self.dependency_dialog.listWidgetAddons.addItem(addon)
+ for mod in missing.python_requires:
+ self.dependency_dialog.listWidgetPythonRequired.addItem(mod)
+ for mod in missing.python_optional:
+ item = QtWidgets.QListWidgetItem(mod)
+ item.setFlags(item.flags() | QtCore.Qt.ItemIsUserCheckable)
+ item.setCheckState(QtCore.Qt.Unchecked)
+ self.dependency_dialog.listWidgetPythonOptional.addItem(item)
+
+ self.dependency_dialog.buttonBox.button(
+ QtWidgets.QDialogButtonBox.Yes
+ ).clicked.connect(self._dependency_dialog_yes_clicked)
+ self.dependency_dialog.buttonBox.button(
+ QtWidgets.QDialogButtonBox.Ignore
+ ).clicked.connect(self._dependency_dialog_ignore_clicked)
+ self.dependency_dialog.buttonBox.button(
+ QtWidgets.QDialogButtonBox.Cancel
+ ).setDefault(True)
+ self.dependency_dialog.exec()
+
+ def _check_python_version(self, missing: MissingDependencies) -> bool:
+ """Make sure we have a compatible Python version. Returns True to stop the installation
+ or False to continue."""
+
+ # For now only look at the minor version, since major is always Python 3
+ minor_required = missing.python_min_version["minor"]
+ if sys.version_info.minor < minor_required:
+ # pylint: disable=line-too-long
+ QtWidgets.QMessageBox.critical(
+ None,
+ translate("AddonsInstaller", "Incompatible Python version"),
+ translate(
+ "AddonsInstaller",
+ "This Addon (or one if its dependencies) requires Python {}.{}, and your system is running {}.{}. Installation cancelled.",
+ ).format(
+ missing.python_min_version["major"],
+ missing.python_min_version["minor"],
+ sys.version_info.major,
+ sys.version_info.minor,
+ ),
+ QtWidgets.QMessageBox.Cancel,
+ )
+ return True
+ return False
+
+ def _clean_up_optional(self, missing: MissingDependencies):
+ good_packages = []
+ for dep in missing.python_optional:
+ if dep in self.installer.allowed_packages:
+ good_packages.append(dep)
+ else:
+ FreeCAD.Console.PrintWarning(
+ translate(
+ "AddonsInstaller",
+ "Optional dependency on {} ignored because it is not in the allow-list\n",
+ ).format(dep)
+ )
+ missing.python_optional = good_packages
+
+ def _dependency_dialog_yes_clicked(self) -> None:
+ # Get the lists out of the dialog:
+ addons = []
+ for row in range(self.dependency_dialog.listWidgetAddons.count()):
+ item = self.dependency_dialog.listWidgetAddons.item(row)
+ name = item.text()
+ for repo in self.addons:
+ if repo.name == name or (
+ hasattr(repo, "display_name") and repo.display_name == name
+ ):
+ addons.append(repo)
+
+ python_requires = []
+ for row in range(self.dependency_dialog.listWidgetPythonRequired.count()):
+ item = self.dependency_dialog.listWidgetPythonRequired.item(row)
+ python_requires.append(item.text())
+
+ python_optional = []
+ for row in range(self.dependency_dialog.listWidgetPythonOptional.count()):
+ item = self.dependency_dialog.listWidgetPythonOptional.item(row)
+ if item.checkState() == QtCore.Qt.Checked:
+ python_optional.append(item.text())
+
+ self._run_dependency_installer(addons, python_requires, python_optional)
+
+ def _run_dependency_installer(self, addons, python_requires, python_optional):
+ """Run the dependency installer (in a separate thread) for the given dependencies"""
+ self.dependency_installer = DependencyInstaller(
+ addons, python_requires, python_optional
+ )
+ self.dependency_installer.no_python_exe.connect(self._report_no_python_exe)
+ self.dependency_installer.no_pip.connect(self._report_no_pip)
+ self.dependency_installer.failure.connect(self._report_dependency_failure)
+ self.dependency_installer.finished.connect(self._cleanup_dependency_worker)
+ self.dependency_installer.finished.connect(self._report_dependency_success)
+
+ self.dependency_worker_thread = QtCore.QThread(self)
+ self.dependency_installer.moveToThread(self.dependency_worker_thread)
+ self.dependency_worker_thread.started.connect(self.dependency_installer.run)
+ self.dependency_installer.finished.connect(self.dependency_worker_thread.quit)
+
+ self.dependency_installation_dialog = QtWidgets.QMessageBox(
+ QtWidgets.QMessageBox.Information,
+ translate("AddonsInstaller", "Installing dependencies"),
+ translate("AddonsInstaller", "Installing dependencies") + "...",
+ QtWidgets.QMessageBox.Cancel,
+ )
+ self.dependency_installation_dialog.rejected.connect(
+ self._cancel_dependency_installation
+ )
+ self.dependency_installation_dialog.show()
+ self.dependency_worker_thread.start()
+
+ def _cleanup_dependency_worker(self) -> None:
+ self.dependency_worker_thread.quit()
+ self.dependency_worker_thread.wait(500)
+ if self.dependency_worker_thread.isRunning():
+ self.dependency_worker_thread.terminate()
+
+ def _report_no_python_exe(self) -> None:
+ """Callback for the dependency installer failing to locate a Python executable."""
+ if self.dependency_installation_dialog is not None:
+ self.dependency_installation_dialog.hide()
+ # pylint: disable=line-too-long
+ result = QtWidgets.QMessageBox.critical(
+ None,
+ translate("AddonsInstaller", "Cannot execute Python"),
+ translate(
+ "AddonsInstaller",
+ "Failed to automatically locate your Python executable, or the path is set incorrectly. Please check the Addon Manager preferences setting for the path to Python.",
+ )
+ + "\n\n"
+ + translate(
+ "AddonsInstaller",
+ "Dependencies could not be installed. Continue with installation of {} anyway?",
+ ).format(self.addon_to_install.name),
+ QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
+ )
+ if result == QtWidgets.QMessageBox.Yes:
+ self.install()
+ else:
+ self.finished.emit()
+
+ def _report_no_pip(self, command: str) -> None:
+ """Callback for the dependency installer failing to access pip."""
+ if self.dependency_installation_dialog is not None:
+ self.dependency_installation_dialog.hide()
+ # pylint: disable=line-too-long
+ result = QtWidgets.QMessageBox.critical(
+ None,
+ translate("AddonsInstaller", "Cannot execute pip"),
+ translate(
+ "AddonsInstaller",
+ "Failed to execute pip, which may be missing from your Python installation. Please ensure your system has pip installed and try again. The failed command was: ",
+ )
+ + f"\n\n{command}\n\n"
+ + translate(
+ "AddonsInstaller",
+ "Continue with installation of {} anyway?",
+ ).format(self.addon_to_install.name),
+ QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
+ )
+ if result == QtWidgets.QMessageBox.Yes:
+ self.install()
+ else:
+ self.finished.emit()
+
+ def _report_dependency_failure(self, short_message: str, details: str) -> None:
+ """Callback for dependency installation failure."""
+ if self.dependency_installation_dialog is not None:
+ self.dependency_installation_dialog.hide()
+ if self.dependency_installer and hasattr(self.dependency_installer, "finished"):
+ self.dependency_installer.finished.disconnect(
+ self._report_dependency_success
+ )
+ FreeCAD.Console.PrintError(details + "\n")
+ result = QtWidgets.QMessageBox.critical(
+ None,
+ translate("AddonsInstaller", "Package installation failed"),
+ short_message
+ + "\n\n"
+ + translate("AddonsInstaller", "See Report View for detailed failure log.")
+ + "\n\n"
+ + translate(
+ "AddonsInstaller",
+ "Continue with installation of {} anyway?",
+ ).format(self.addon_to_install.name),
+ QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No,
+ )
+ if result == QtWidgets.QMessageBox.Yes:
+ self.install()
+ else:
+ self.finished.emit()
+
+ def _report_dependency_success(self):
+ """Callback for dependency installation success."""
+ if self.dependency_installation_dialog is not None:
+ self.dependency_installation_dialog.hide()
+ self.install()
+
+ def _dependency_dialog_ignore_clicked(self) -> None:
+ """Callback for when dependencies are ignored."""
+ self.install()
+
+ def _cancel_dependency_installation(self) -> None:
+ """Cancel was clicked in the dialog. NOTE: Does no cleanup, the state after cancellation is
+ unknown. In most cases pip can recover from whatever we've done to it."""
+ self.dependency_worker_thread.blockSignals(True)
+ self.dependency_installer.blockSignals(True)
+ self.dependency_worker_thread.requestInterruption()
+ self.dependency_installation_dialog.hide()
+ self.dependency_worker_thread.wait()
+ self.finished.emit()
+
+ def install(self) -> None:
+ """Installs or updates a workbench, macro, or package"""
+ self.worker_thread = QtCore.QThread()
+ self.installer.moveToThread(self.worker_thread)
+ self.installer.finished.connect(self.worker_thread.quit)
+ self.worker_thread.started.connect(self.installer.run)
+ self.worker_thread.start() # Returns immediately
+
+ self.installing_dialog = QtWidgets.QMessageBox(
+ QtWidgets.QMessageBox.NoIcon,
+ translate("AddonsInstaller", "Installing Addon"),
+ translate("AddonsInstaller", "Installing FreeCAD Addon '{}'").format(
+ self.addon_to_install.display_name
+ ),
+ QtWidgets.QMessageBox.Cancel,
+ )
+ self.installing_dialog.rejected.connect(self._cancel_addon_installation)
+ self.installer.finished.connect(self.installing_dialog.hide)
+ self.installing_dialog.show()
+
+ def _cancel_addon_installation(self):
+ dlg = QtWidgets.QMessageBox(
+ QtWidgets.QMessageBox.NoIcon,
+ translate("AddonsInstaller", "Cancelling"),
+ translate("AddonsInstaller", "Cancelling installation of '{}'").format(
+ self.addon_to_install.display_name
+ ),
+ QtWidgets.QMessageBox.NoButton,
+ )
+ dlg.show()
+ if self.worker_thread.isRunning():
+ # Interruption can take a second or more, depending on what was being done. Make sure
+ # we stay responsive and update the dialog with the text above, etc.
+ self.worker_thread.requestInterruption()
+ self.worker_thread.quit()
+ while self.worker_thread.isRunning():
+ self.worker_thread.wait(50)
+ QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents)
+ path = os.path.join(
+ self.installer.installation_path, self.addon_to_install.name
+ )
+ if os.path.exists(path):
+ utils.rmdir(path)
+ dlg.hide()
+ self.finished.emit()
+
+ def _installation_succeeded(self):
+ """Called if the installation was successful."""
+ QtWidgets.QMessageBox.information(
+ None,
+ translate("AddonsInstaller", "Success"),
+ translate("AddonsInstaller", "{} was installed successfully").format(
+ self.addon_to_install.name
+ ),
+ QtWidgets.QMessageBox.Ok,
+ )
+ self.success.emit(self.addon_to_install)
+ self.finished.emit()
+
+ def _installation_failed(self, addon, message):
+ """Called if the installation failed."""
+ QtWidgets.QMessageBox.critical(
+ None,
+ translate("AddonsInstaller", "Installation Failed"),
+ translate("AddonsInstaller", "Failed to install {}").format(addon.name)
+ + "\n"
+ + message,
+ QtWidgets.QMessageBox.Cancel,
+ )
+ self.finished.emit()
+
+
+class MacroInstallerGUI(QtCore.QObject):
+ """Install a macro, providing feedback about the process via dialog boxes, and then offer to
+ add the macro to a custom toolbar. Should be run on the main GUI thread: this class internally
+ launches a QThread for the actual installation process."""
+
+ # Only success should matter to external code: all user interaction is handled via this class
+ success = QtCore.Signal(object)
+
+ # Emitted once all work has been completed, regardless of success or failure
+ finished = QtCore.Signal()
+
+ def __init__(self, addon: object):
+ """The provided addon object must have an attribute called "macro", and that attribute must
+ itself provide a callable "install" method that takes a single string, the path to the
+ installation location."""
+ self.addon_to_install = addon
+ self.worker_thread = None
+ self.installer = MacroInstaller(self.addon_to_install)
+ self.addon_params = FreeCAD.ParamGet(
+ "User parameter:BaseApp/Preferences/Addons"
+ )
+ self.toolbar_params = FreeCAD.ParamGet(
+ "User parameter:BaseApp/Workbench/Global/Toolbar"
+ )
+ self.macro_dir = FreeCAD.getUserMacroDir(True)
+
+ def run(self):
+ """Perform the installation, including any necessary user interaction via modal dialog
+ boxes. If installation proceeds successfully to completion, emits the success() signal."""
+
+ self.worker_thread = QtCore.QThread()
+ self.installer.moveToThread(self.worker_thread)
+ self.installer.finished.connect(self.worker_thread.quit)
+ self.installer.success.connect(self._base_installation_success)
+ self.worker_thread.started.connect(self.installer.run)
+ self.worker_thread.start() # Returns immediately
+
+ def _base_installation_success(self):
+ """Callback for a successful basic macro installation."""
+ self.success.emit(self.addon_to_install)
+ self._ask_to_install_toolbar_button() # Synchronous set of modals
+ self.finished.emit()
+
+ def _ask_to_install_toolbar_button(self) -> None:
+ """Presents a dialog to the user asking if they want to install a toolbar button for
+ a particular macro, and walks through that process if they agree to do so."""
+ do_not_show_dialog = self.addon_params.GetBool(
+ "dontShowAddMacroButtonDialog", False
+ )
+ button_exists = self._macro_button_exists()
+ if not do_not_show_dialog and not button_exists:
+ add_toolbar_button_dialog = FreeCADGui.PySideUic.loadUi(
+ os.path.join(os.path.dirname(__file__), "add_toolbar_button_dialog.ui")
+ )
+ add_toolbar_button_dialog.setWindowFlag(
+ QtCore.Qt.WindowStaysOnTopHint, True
+ )
+ add_toolbar_button_dialog.buttonYes.clicked.connect(
+ self._install_toolbar_button
+ )
+ add_toolbar_button_dialog.buttonNever.clicked.connect(
+ lambda: self.addon_params.SetBool("dontShowAddMacroButtonDialog", True)
+ )
+ add_toolbar_button_dialog.exec()
+
+ def _find_custom_command(self, filename):
+ """Wrap calls to FreeCADGui.Command.findCustomCommand so it can be faked in testing."""
+ return FreeCADGui.Command.findCustomCommand(filename)
+
+ def _macro_button_exists(self) -> bool:
+ """Returns True if a button already exists for this macro, or False if not."""
+ command = self._find_custom_command(self.addon_to_install.macro.filename)
+ if not command:
+ return False
+ toolbar_groups = self.toolbar_params.GetGroups()
+ for group in toolbar_groups:
+ toolbar = self.toolbar_params.GetGroup(group)
+ if toolbar.GetString(command, "*") != "*":
+ return True
+ return False
+
+ def _ask_for_toolbar(self, custom_toolbars) -> object:
+ """Determine what toolbar to add the icon to. The first time it is called it prompts the
+ user to select or create a toolbar. After that, the prompt is optional and can be configured
+ via a preference. Returns the pref group for the new toolbar."""
+
+ # In this one spot, default True: if this is the first time we got to
+ # this chunk of code, we are always going to ask.
+ ask = self.addon_params.GetBool("alwaysAskForToolbar", True)
+
+ if ask:
+ select_toolbar_dialog = FreeCADGui.PySideUic.loadUi(
+ os.path.join(os.path.dirname(__file__), "select_toolbar_dialog.ui")
+ )
+ select_toolbar_dialog.setWindowFlag(QtCore.Qt.WindowStaysOnTopHint, True)
+
+ select_toolbar_dialog.comboBox.clear()
+
+ for group in custom_toolbars:
+ ref = self.toolbar_params.GetGroup(group)
+ name = ref.GetString("Name", "")
+ if name:
+ select_toolbar_dialog.comboBox.addItem(name)
+ else:
+ FreeCAD.Console.PrintWarning(
+ f"Custom toolbar {group} does not have a Name element\n"
+ )
+ new_menubar_option_text = translate("AddonsInstaller", "Create new toolbar")
+ select_toolbar_dialog.comboBox.addItem(new_menubar_option_text)
+
+ result = select_toolbar_dialog.exec()
+ if result == QtWidgets.QDialog.Accepted:
+ selection = select_toolbar_dialog.comboBox.currentText()
+ if select_toolbar_dialog.checkBox.checkState() == QtCore.Qt.Unchecked:
+ self.addon_params.SetBool("alwaysAskForToolbar", False)
+ else:
+ self.addon_params.SetBool("alwaysAskForToolbar", True)
+ if selection == new_menubar_option_text:
+ return self._create_new_custom_toolbar()
+ return self._get_toolbar_with_name(selection)
+ return None
+
+ # If none of the above code returned...
+ custom_toolbar_name = self.addon_params.GetString(
+ "CustomToolbarName", "Auto-Created Macro Toolbar"
+ )
+ toolbar = self._get_toolbar_with_name(custom_toolbar_name)
+ if not toolbar:
+ # They told us not to ask, but then the toolbar got deleted... ask anyway!
+ ask = self.addon_params.RemBool("alwaysAskForToolbar")
+ return self._ask_for_toolbar(custom_toolbars)
+ return toolbar
+
+ def _get_toolbar_with_name(self, name: str) -> object:
+ """Try to find a toolbar with a given name. Returns the preference group for the toolbar
+ if found, or None if it does not exist."""
+ custom_toolbars = self.toolbar_params.GetGroups()
+ for toolbar in custom_toolbars:
+ group = self.toolbar_params.GetGroup(toolbar)
+ group_name = group.GetString("Name", "")
+ if group_name == name:
+ return group
+ return None
+
+ def _create_new_custom_toolbar(self) -> object:
+ """Create a new custom toolbar and returns its preference group."""
+
+ # We need two names: the name of the auto-created toolbar, as it will be displayed to the
+ # user in various menus, and the underlying name of the toolbar group. Both must be
+ # unique.
+
+ # First, the displayed name
+ custom_toolbar_name = "Auto-Created Macro Toolbar"
+ custom_toolbars = self.toolbar_params.GetGroups()
+ name_taken = self._check_for_toolbar(custom_toolbar_name)
+ if name_taken:
+ i = 2 # Don't use (1), start at (2)
+ while True:
+ test_name = custom_toolbar_name + f" ({i})"
+ if not self._check_for_toolbar(test_name):
+ custom_toolbar_name = test_name
+ break
+ i = i + 1
+
+ # Second, the toolbar preference group name
+ i = 1
+ while True:
+ new_group_name = "Custom_" + str(i)
+ if new_group_name not in custom_toolbars:
+ break
+ i = i + 1
+
+ custom_toolbar = self.toolbar_params.GetGroup(new_group_name)
+ custom_toolbar.SetString("Name", custom_toolbar_name)
+ custom_toolbar.SetBool("Active", True)
+ return custom_toolbar
+
+ def _check_for_toolbar(self, toolbar_name: str) -> bool:
+ """Returns True if the toolbar exists, otherwise False"""
+ return self._get_toolbar_with_name(toolbar_name) is not None
+
+ def _install_toolbar_button(self) -> None:
+ """If the user has requested a toolbar button be installed, this function is called
+ to continue the process and request any additional required information."""
+ custom_toolbar_name = self.addon_params.GetString(
+ "CustomToolbarName", "Auto-Created Macro Toolbar"
+ )
+
+ # Default to false here: if the variable hasn't been set, we don't assume
+ # that we have to ask, because the simplest is to just create a new toolbar
+ # and never ask at all.
+ ask = self.addon_params.GetBool("alwaysAskForToolbar", False)
+
+ # See if there is already a custom toolbar for macros:
+ top_group = self.toolbar_params
+ custom_toolbars = top_group.GetGroups()
+ if custom_toolbars:
+ # If there are already custom toolbars, see if one of them is the one we used last time
+ found_toolbar = False
+ for toolbar_name in custom_toolbars:
+ test_toolbar = self.toolbar_params.GetGroup(toolbar_name)
+ name = test_toolbar.GetString("Name", "")
+ if name == custom_toolbar_name:
+ custom_toolbar = test_toolbar
+ found_toolbar = True
+ break
+ if ask or not found_toolbar:
+ # We have to ask the user what to do...
+ custom_toolbar = self._ask_for_toolbar(custom_toolbars)
+ if custom_toolbar:
+ custom_toolbar_name = custom_toolbar.GetString("Name")
+ self.addon_params.SetString(
+ "CustomToolbarName", custom_toolbar_name
+ )
+ else:
+ # Create a custom toolbar
+ custom_toolbar = self.toolbar_params.GetGroup("Custom_1")
+ custom_toolbar.SetString("Name", custom_toolbar_name)
+ custom_toolbar.SetBool("Active", True)
+
+ if custom_toolbar:
+ self._install_macro_to_toolbar(custom_toolbar)
+ else:
+ FreeCAD.Console.PrintMessage(
+ "In the end, no custom toolbar was set, bailing out\n"
+ )
+
+ def _install_macro_to_toolbar(self, toolbar: object) -> None:
+ """Adds an icon for the given macro to the given toolbar."""
+ menuText = self.addon_to_install.display_name
+ tooltipText = f"{self.addon_to_install.display_name}"
+ if self.addon_to_install.macro.comment:
+ tooltipText += f"
{self.addon_to_install.macro.comment}
"
+ whatsThisText = self.addon_to_install.macro.comment
+ else:
+ whatsThisText = translate(
+ "AddonsInstaller", "A macro installed with the FreeCAD Addon Manager"
+ )
+ statustipText = (
+ translate("AddonsInstaller", "Run", "Indicates a macro that can be 'run'")
+ + " "
+ + self.addon_to_install.display_name
+ )
+ if self.addon_to_install.macro.icon:
+ if os.path.isabs(self.addon_to_install.macro.icon):
+ pixmapText = os.path.normpath(self.addon_to_install.macro.icon)
+ else:
+ pixmapText = os.path.normpath(
+ os.path.join(self.macro_dir, self.addon_to_install.macro.icon)
+ )
+ elif self.addon_to_install.macro.xpm:
+ icon_file = os.path.normpath(
+ os.path.join(
+ self.macro_dir, self.addon_to_install.macro.name + "_icon.xpm"
+ )
+ )
+ with open(icon_file, "w", encoding="utf-8") as f:
+ f.write(self.addon_to_install.macro.xpm)
+ pixmapText = icon_file
+ else:
+ pixmapText = None
+
+ # Add this command to that toolbar
+ self._create_custom_command(
+ toolbar,
+ self.addon_to_install.macro.filename,
+ menuText,
+ tooltipText,
+ whatsThisText,
+ statustipText,
+ pixmapText,
+ )
+
+ # pylint: disable=too-many-arguments
+ def _create_custom_command(
+ self,
+ toolbar,
+ filename,
+ menuText,
+ tooltipText,
+ whatsThisText,
+ statustipText,
+ pixmapText,
+ ):
+ """Wrap createCustomCommand so it can be overridden during testing."""
+ # Add this command to that toolbar
+ command_name = FreeCADGui.Command.createCustomCommand(
+ filename,
+ menuText,
+ tooltipText,
+ whatsThisText,
+ statustipText,
+ pixmapText,
+ )
+ toolbar.SetString(command_name, "FreeCAD")
+
+ # Force the toolbars to be recreated
+ wb = FreeCADGui.activeWorkbench()
+ wb.reloadActive()
+
+ def _remove_custom_toolbar_button(self) -> None:
+ """If this repo contains a macro, look through the custom commands and
+ see if one is set up for this macro. If so, remove it, including any
+ toolbar entries."""
+
+ command = FreeCADGui.Command.findCustomCommand(
+ self.addon_to_install.macro.filename
+ )
+ if not command:
+ return
+ custom_toolbars = FreeCAD.ParamGet(
+ "User parameter:BaseApp/Workbench/Global/Toolbar"
+ )
+ toolbar_groups = custom_toolbars.GetGroups()
+ for group in toolbar_groups:
+ toolbar = custom_toolbars.GetGroup(group)
+ if toolbar.GetString(command, "*") != "*":
+ toolbar.RemString(command)
+
+ FreeCADGui.Command.removeCustomCommand(command)
+
+ # Force the toolbars to be recreated
+ wb = FreeCADGui.activeWorkbench()
+ wb.reloadActive()
diff --git a/src/Mod/AddonManager/addonmanager_macro.py b/src/Mod/AddonManager/addonmanager_macro.py
index bf67aaa869..e56c763891 100644
--- a/src/Mod/AddonManager/addonmanager_macro.py
+++ b/src/Mod/AddonManager/addonmanager_macro.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
# ***************************************************************************
# * *
# * Copyright (c) 2018 Gaël Écorchard *
diff --git a/src/Mod/AddonManager/addonmanager_update_all_gui.py b/src/Mod/AddonManager/addonmanager_update_all_gui.py
new file mode 100644
index 0000000000..dc7025bdb0
--- /dev/null
+++ b/src/Mod/AddonManager/addonmanager_update_all_gui.py
@@ -0,0 +1,202 @@
+# ***************************************************************************
+# * *
+# * 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 *
+# * . *
+# * *
+# ***************************************************************************
+
+"""Class to manage the display of an Update All dialog."""
+
+from enum import IntEnum, auto
+import os
+from typing import List
+
+import FreeCAD
+import FreeCADGui
+
+from PySide import QtCore, QtWidgets
+
+from Addon import Addon
+
+from addonmanager_installer import AddonInstaller, MacroInstaller
+
+translate = FreeCAD.Qt.translate
+
+# pylint: disable=too-few-public-methods,too-many-instance-attributes
+
+
+class UpdaterFactory:
+ """A factory class for generating updaters. Mainly exists to allow eaily mocking those
+ updaters during testing. A replacement class need only provide a "get_updater" function
+ that returns mock updater objects. Those objects must be QObjects with a run() function
+ and a finished signal."""
+
+ def __init__(self, addons):
+ self.addons = addons
+
+ def get_updater(self, addon):
+ """Get an updater for this addon (either a MacroInstaller or an AddonInstaller)"""
+ if addon.macro is not None:
+ return MacroInstaller(addon)
+ return AddonInstaller(addon, self.addons)
+
+
+class AddonStatus(IntEnum):
+ """The current status of the installation process for a given addon"""
+
+ WAITING = auto()
+ INSTALLING = auto()
+ SUCCEEDED = auto()
+ FAILED = auto()
+
+ def ui_string(self):
+ """Get the string that the UI should show for this status"""
+ if self.value == AddonStatus.WAITING:
+ return ""
+ if self.value == AddonStatus.INSTALLING:
+ return translate("AddonsInstaller", "Installing") + "..."
+ if self.value == AddonStatus.SUCCEEDED:
+ return translate("AddonsInstaller", "Succeeded")
+ if self.value == AddonStatus.FAILED:
+ return translate("AddonsInstaller", "Failed")
+ return "[INTERNAL ERROR]"
+
+
+class UpdateAllGUI(QtCore.QObject):
+ """A GUI to display and manage an "update all" process."""
+
+ finished = QtCore.Signal()
+ addon_updated = QtCore.Signal(object)
+
+ def __init__(self, addons: List[Addon]):
+ super().__init__()
+ self.addons = addons
+ self.dialog = FreeCADGui.PySideUic.loadUi(
+ os.path.join(os.path.dirname(__file__), "update_all.ui")
+ )
+ self.row_map = {}
+ self.in_process_row = None
+ self.active_installer = None
+ self.addons_with_update: List[Addon] = []
+ self.updater_factory = UpdaterFactory(addons)
+ self.worker_thread = None
+ self.running = False
+ self.cancelled = False
+
+ def run(self):
+ """Run the Update All process. Blocks until updates are complete or cancelled."""
+ self.running = True
+ self._setup_dialog()
+ self.dialog.show()
+ self._process_next_update()
+
+ def _setup_dialog(self):
+ """Prepare the dialog for display"""
+ self.dialog.rejected.connect(self._cancel_installation)
+ self.dialog.tableWidget.clear()
+ self.in_process_row = None
+ self.row_map = {}
+ for addon in self.addons:
+ if addon.status() == Addon.Status.UPDATE_AVAILABLE:
+ self._add_addon_to_table(addon)
+ self.addons_with_update.append(addon)
+
+ def _cancel_installation(self):
+ self.cancelled = True
+ self.worker_thread.requestInterruption()
+ self.worker_thread.wait(100)
+ if self.worker_thread.isRunning():
+ self.worker_thread.terminate()
+ self.worker_thread.wait()
+ self.running = False
+
+ def _add_addon_to_table(self, addon: Addon):
+ """Add the given addon to the list, with no icon in the first column"""
+ new_row = self.dialog.tableWidget.rowCount()
+ self.dialog.tableWidget.setColumnCount(2)
+ self.dialog.tableWidget.setRowCount(new_row + 1)
+ self.dialog.tableWidget.setItem(
+ new_row, 0, QtWidgets.QTableWidgetItem(addon.display_name)
+ )
+ self.dialog.tableWidget.setItem(new_row, 1, QtWidgets.QTableWidgetItem(""))
+ self.row_map[addon.name] = new_row
+
+ def _update_addon_status(self, row: int, status: AddonStatus):
+ """Update the GUI to reflect this addon's new status."""
+ self.dialog.tableWidget.item(row, 1).setText(status.ui_string())
+
+ def _process_next_update(self):
+ """Grab the next addon in the list and start its updater."""
+ if self.addons_with_update:
+ addon = self.addons_with_update.pop(0)
+ self.in_process_row = (
+ self.row_map[addon.name] if addon.name in self.row_map else None
+ )
+ self._update_addon_status(self.in_process_row, AddonStatus.INSTALLING)
+ self.dialog.tableWidget.scrollToItem(
+ self.dialog.tableWidget.item(self.in_process_row, 0)
+ )
+ self.active_installer = self.updater_factory.get_updater(addon)
+ self._launch_active_installer()
+ else:
+ self._finalize()
+
+ def _launch_active_installer(self):
+ """Set up and run the active installer in a new thread."""
+
+ self.active_installer.success.connect(self._update_succeeded)
+ self.active_installer.failure.connect(self._update_failed)
+ self.active_installer.finished.connect(self._update_finished)
+
+ self.worker_thread = QtCore.QThread()
+ self.active_installer.moveToThread(self.worker_thread)
+ self.worker_thread.started.connect(self.active_installer.run)
+ self.worker_thread.start()
+
+ def _update_succeeded(self, addon):
+ """Callback for a successful update"""
+ self._update_addon_status(self.row_map[addon.name], AddonStatus.SUCCEEDED)
+ self.addon_updated.emit(addon)
+
+ def _update_failed(self, addon):
+ """Callback for a failed update"""
+ self._update_addon_status(self.row_map[addon.name], AddonStatus.FAILED)
+
+ def _update_finished(self):
+ """Callback for updater that has finished all its work"""
+ self.worker_thread.terminate()
+ self.worker_thread.wait()
+ self.addon_updated.emit(self.active_installer.addon_to_install)
+ if not self.cancelled:
+ self._process_next_update()
+
+ def _finalize(self):
+ """No more updates, clean up and shut down"""
+ if self.worker_thread is not None and self.worker_thread.isRunning():
+ self.worker_thread.terminate()
+ self.worker_thread.wait()
+ self.dialog.buttonBox.clear()
+ self.dialog.buttonBox.addButton(QtWidgets.QDialogButtonBox.Close)
+ self.dialog.label.setText(
+ translate("Addons installer", "Finished updating the following addons")
+ )
+ self.running = False
+
+ def is_running(self):
+ """True if the thread is running, and False if not"""
+ return self.running
diff --git a/src/Mod/AddonManager/addonmanager_utilities.py b/src/Mod/AddonManager/addonmanager_utilities.py
index 772042e8de..77f30c47c8 100644
--- a/src/Mod/AddonManager/addonmanager_utilities.py
+++ b/src/Mod/AddonManager/addonmanager_utilities.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
# ***************************************************************************
# * *
# * Copyright (c) 2018 Gaël Écorchard *
@@ -26,6 +25,8 @@
import os
import platform
import shutil
+import stat
+import subprocess
import re
import ctypes
from typing import Optional, Any
@@ -35,7 +36,24 @@ from urllib.parse import urlparse
from PySide import QtCore, QtWidgets
import FreeCAD
-import FreeCADGui
+
+if FreeCAD.GuiUp:
+ import FreeCADGui
+
+ # If the GUI is up, we can use the NetworkManager to handle our downloads. If there is no event
+ # loop running this is not possible, so fall back to requests (if available), or the native
+ # Python urllib.request (if requests is not available).
+ import NetworkManager # Requires an event loop, so is only available with the GUI
+else:
+ has_requests = False
+ try:
+ import requests
+
+ has_requests = True
+ except ImportError:
+ has_requests = False
+ import urllib.request
+ import ssl
# @package AddonManager_utilities
@@ -47,6 +65,10 @@ import FreeCADGui
translate = FreeCAD.Qt.translate
+class ProcessInterrupted(RuntimeError):
+ """An interruption request was received and the process killed because if it."""
+
+
def symlink(source, link_name):
"""Creates a symlink of a file, if possible. Note that it fails on most modern Windows
installations"""
@@ -72,6 +94,21 @@ def symlink(source, link_name):
raise ctypes.WinError()
+def rmdir(path: os.PathLike) -> bool:
+ try:
+ shutil.rmtree(path, onerror=remove_readonly)
+ except Exception:
+ return False
+ return True
+
+
+def remove_readonly(func, path, _) -> None:
+ """Remove a read-only file."""
+
+ os.chmod(path, stat.S_IWRITE)
+ func(path)
+
+
def update_macro_details(old_macro, new_macro):
"""Update a macro with information from another one
@@ -122,9 +159,9 @@ def get_zip_url(repo):
if parsed_url.netloc in ["gitlab.com", "framagit.org", "salsa.debian.org"]:
return f"{repo.url}/-/archive/{repo.branch}/{repo.name}-{repo.branch}.zip"
FreeCAD.Console.PrintLog(
- "Debug: addonmanager_utilities.get_zip_url: Unknown git host fetching zip URL:",
- parsed_url.netloc,
- "\n",
+ "Debug: addonmanager_utilities.get_zip_url: Unknown git host fetching zip URL:"
+ + parsed_url.netloc
+ + "\n"
)
return f"{repo.url}/-/archive/{repo.branch}/{repo.name}-{repo.branch}.zip"
@@ -359,3 +396,58 @@ def get_cache_file_name(file: str) -> str:
am_path = os.path.join(cache_path, "AddonManager")
os.makedirs(am_path, exist_ok=True)
return os.path.join(am_path, file)
+
+
+def blocking_get(url: str, method=None) -> str:
+ """Wrapper around three possible ways of accessing data, depending on the current run mode and
+ Python installation. Blocks until complete, and returns the text results of the call if it
+ succeeded, or an empty string if it failed, or returned no data. The method argument is
+ provided mainly for testing purposes."""
+ p = ""
+ if FreeCAD.GuiUp and method is None or method == "networkmanager":
+ NetworkManager.InitializeNetworkManager()
+ p = NetworkManager.AM_NETWORK_MANAGER.blocking_get(url)
+ elif has_requests and method is None or method == "requests":
+ response = requests.get(url)
+ if response.status_code == 200:
+ p = response.text
+ else:
+ ctx = ssl.create_default_context()
+ with urllib.request.urlopen(url, context=ctx) as f:
+ p = f.read().decode("utf-8")
+ return p
+
+
+def run_interruptable_subprocess(args) -> object:
+ """Wrap subprocess call so it can be interrupted gracefully."""
+ creationflags = 0
+ if hasattr(subprocess, "CREATE_NO_WINDOW"):
+ # Added in Python 3.7 -- only used on Windows
+ creationflags = subprocess.CREATE_NO_WINDOW
+ try:
+ p = subprocess.Popen(
+ args,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ creationflags=creationflags,
+ text=True,
+ encoding="utf-8",
+ )
+ except OSError as e:
+ raise subprocess.CalledProcessError(-1, args, "", e.strerror)
+ stdout = ""
+ stderr = ""
+ return_code = None
+ while return_code is None:
+ try:
+ stdout, stderr = p.communicate(timeout=0.1)
+ return_code = p.returncode if p.returncode is not None else -1
+ except subprocess.TimeoutExpired:
+ if QtCore.QThread.currentThread().isInterruptionRequested():
+ p.kill()
+ stdout, stderr = p.communicate()
+ return_code = -1
+ raise ProcessInterrupted()
+ if return_code is None or return_code != 0:
+ raise subprocess.CalledProcessError(return_code, args, stdout, stderr)
+ return subprocess.CompletedProcess(args, return_code, stdout, stderr)
diff --git a/src/Mod/AddonManager/addonmanager_workers_installation.py b/src/Mod/AddonManager/addonmanager_workers_installation.py
index 9dafb65da6..a751dfe415 100644
--- a/src/Mod/AddonManager/addonmanager_workers_installation.py
+++ b/src/Mod/AddonManager/addonmanager_workers_installation.py
@@ -40,7 +40,6 @@ import FreeCAD
import addonmanager_utilities as utils
from Addon import Addon
import NetworkManager
-from addonmanager_git import initialize_git
translate = FreeCAD.Qt.translate
@@ -50,482 +49,6 @@ translate = FreeCAD.Qt.translate
# @{
-class InstallWorkbenchWorker(QtCore.QThread):
- "This worker installs a workbench"
-
- status_message = QtCore.Signal(str)
- progress_made = QtCore.Signal(int, int)
- success = QtCore.Signal(Addon, str)
- failure = QtCore.Signal(Addon, str)
-
- def __init__(self, repo: Addon, location=None):
-
- QtCore.QThread.__init__(self)
- self.repo = repo
- self.update_timer = QtCore.QTimer()
- self.update_timer.setInterval(100)
- self.update_timer.timeout.connect(self.update_status)
- self.update_timer.start()
-
- if location:
- self.clone_directory = location
- else:
- basedir = FreeCAD.getUserAppDataDir()
- self.clone_directory = os.path.join(basedir, "Mod", repo.name)
-
- if not os.path.exists(self.clone_directory):
- os.makedirs(self.clone_directory)
-
- self.git_manager = initialize_git()
-
- # Some stored data for the ZIP processing
- self.zip_complete = False
- self.zipdir = None
- self.bakdir = None
- self.zip_download_index = None
-
- def run(self):
- """Normally not called directly: instead, create an instance of this worker class and
- call start() on it to launch in a new thread. Installs or updates the selected addon"""
-
- if not self.repo:
- return
-
- if not self.git_manager:
- FreeCAD.Console.PrintLog(
- translate(
- "AddonsInstaller",
- "Git disabled - using ZIP file download instead.",
- )
- + "\n"
- )
-
- target_dir = self.clone_directory
-
- if self.git_manager:
- # Do the git process...
- self.run_git(target_dir)
- else:
-
- # The zip process uses an event loop, since the download can potentially be quite large
- self.launch_zip(target_dir)
- self.zip_complete = False
- current_thread = QtCore.QThread.currentThread()
- while not self.zip_complete:
- if current_thread.isInterruptionRequested():
- return
- QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 50)
-
- self.repo.set_status(Addon.Status.PENDING_RESTART)
-
- def update_status(self) -> None:
- """Periodically emit the progress of the git download, for asynchronous operations"""
- if hasattr(self, "git_progress") and self.isRunning():
- self.progress_made.emit(self.git_progress.current, self.git_progress.total)
- self.status_message.emit(self.git_progress.message)
-
- def run_git(self, clonedir: str) -> None:
- """Clone or update the addon using git. Exits if git is disabled."""
-
- if not self.git_manager:
- FreeCAD.Console.PrintLog(
- translate(
- "AddonsInstaller",
- "Git disabled, skipping git operations",
- )
- + "\n"
- )
- return
-
- if os.path.exists(clonedir):
- self.run_git_update(clonedir)
- else:
- self.run_git_clone(clonedir)
-
- def run_git_update(self, clonedir: str) -> None:
- """Runs git update operation: normally a fetch and pull, but if something goew wrong it
- will revert to a clean clone."""
- self.status_message.emit("Updating module...")
- with self.repo.git_lock:
- if not os.path.exists(os.path.join(clonedir, ".git")):
- self.git_manager.repair(self.repo.url, clonedir)
- try:
- self.git_manager.update(clonedir)
- if self.repo.contains_workbench():
- answer = translate(
- "AddonsInstaller",
- "Addon successfully updated. Please restart FreeCAD to apply the changes.",
- )
- else:
- answer = translate(
- "AddonsInstaller",
- "Addon successfully updated.",
- )
- except GitFailed as e:
- answer = (
- translate("AddonsInstaller", "Error updating module")
- + " "
- + self.repo.name
- + " - "
- + translate("AddonsInstaller", "Please fix manually")
- + " -- \n"
- )
- answer += str(e)
- self.failure.emit(self.repo, answer)
- self.update_metadata()
- self.success.emit(self.repo, answer)
-
- def run_git_clone(self, clonedir: str) -> None:
- """Clones a repo using git"""
- self.status_message.emit("Cloning module...")
- current_thread = QtCore.QThread.currentThread()
-
- FreeCAD.Console.PrintMessage("Cloning repo...\n")
- if self.repo.git_lock.locked():
- FreeCAD.Console.PrintMessage("Waiting for lock to be released to us...\n")
- if not self.repo.git_lock.acquire(timeout=2):
- FreeCAD.Console.PrintError(
- "Timeout waiting for a lock on the git process, failed to clone repo\n"
- )
- return
- self.repo.git_lock.release()
-
- with self.repo.git_lock:
- FreeCAD.Console.PrintMessage("Lock acquired...\n")
- self.git_manager.clone(self.repo.url, clonedir)
- FreeCAD.Console.PrintMessage("Initial clone complete...\n")
- if current_thread.isInterruptionRequested():
- return
-
- if current_thread.isInterruptionRequested():
- return
-
- FreeCAD.Console.PrintMessage("Clone complete\n")
-
- if self.repo.contains_workbench():
- answer = translate(
- "AddonsInstaller",
- "Addon successfully installed. Please restart FreeCAD to apply the changes.",
- )
- else:
- answer = translate(
- "AddonsInstaller",
- "Addon successfully installed.",
- )
-
- if self.repo.repo_type == Addon.Kind.WORKBENCH:
- # symlink any macro contained in the module to the macros folder
- macro_dir = FreeCAD.getUserMacroDir(True)
- if not os.path.exists(macro_dir):
- os.makedirs(macro_dir)
- if os.path.exists(clonedir):
- for f in os.listdir(clonedir):
- if f.lower().endswith(".fcmacro"):
- try:
- utils.symlink(
- os.path.join(clonedir, f), os.path.join(macro_dir, f)
- )
- except OSError:
- # If the symlink failed (e.g. for a non-admin user on Windows), copy
- # the macro instead
- shutil.copy(
- os.path.join(clonedir, f), os.path.join(macro_dir, f)
- )
- FreeCAD.ParamGet(
- "User parameter:Plugins/" + self.repo.name
- ).SetString("destination", clonedir)
- # pylint: disable=line-too-long
- answer += "\n\n" + translate(
- "AddonsInstaller",
- "A macro has been installed and is available under Macro -> Macros menu",
- )
- answer += ":\n" + f + ""
- self.update_metadata()
- self.success.emit(self.repo, answer)
-
- def launch_zip(self, zipdir: str) -> None:
- """Downloads and unzip a zip version from a git repo"""
-
- bakdir = None
- if os.path.exists(zipdir):
- bakdir = zipdir + ".bak"
- if os.path.exists(bakdir):
- shutil.rmtree(bakdir)
- os.rename(zipdir, bakdir)
- os.makedirs(zipdir)
- zipurl = utils.get_zip_url(self.repo)
- if not zipurl:
- self.failure.emit(
- self.repo,
- translate("AddonsInstaller", "Error: Unable to locate ZIP from")
- + " "
- + self.repo.name,
- )
- return
-
- self.zipdir = zipdir
- self.bakdir = bakdir
-
- NetworkManager.AM_NETWORK_MANAGER.progress_made.connect(self.update_zip_status)
- NetworkManager.AM_NETWORK_MANAGER.progress_complete.connect(self.finish_zip)
- self.zip_download_index = (
- NetworkManager.AM_NETWORK_MANAGER.submit_monitored_get(zipurl)
- )
-
- def update_zip_status(self, index: int, bytes_read: int, data_size: int):
- """Called periodically when downloading a zip file, emits a signal to display the
- download progress."""
- if index == self.zip_download_index:
- locale = QtCore.QLocale()
- if data_size > 10 * 1024 * 1024: # To avoid overflows, show MB instead
- MB_read = bytes_read / 1024 / 1024
- MB_total = data_size / 1024 / 1024
- self.progress_made.emit(MB_read, MB_total)
- mbytes_str = locale.toString(MB_read)
- mbytes_total_str = locale.toString(MB_total)
- percent = int(100 * float(MB_read / MB_total))
- self.status_message.emit(
- translate(
- "AddonsInstaller",
- "Downloading: {mbytes_str}MB of {mbytes_total_str}MB ({percent}%)",
- ).format(
- mbytes_str=mbytes_str,
- mbytes_total_str=mbytes_total_str,
- percent=percent,
- )
- )
- elif data_size > 0:
- self.progress_made.emit(bytes_read, data_size)
- bytes_str = locale.toString(bytes_read)
- bytes_total_str = locale.toString(data_size)
- percent = int(100 * float(bytes_read / data_size))
- self.status_message.emit(
- translate(
- "AddonsInstaller",
- "Downloading: {bytes_str} of {bytes_total_str} bytes ({percent}%)",
- ).format(
- bytes_str=bytes_str,
- bytes_total_str=bytes_total_str,
- percent=percent,
- )
- )
- else:
- MB_read = bytes_read / 1024 / 1024
- bytes_str = locale.toString(MB_read)
- self.status_message.emit(
- translate(
- "AddonsInstaller",
- "Downloading: {bytes_str}MB of unknown total",
- ).format(bytes_str=bytes_str)
- )
-
- def finish_zip(self, _index: int, response_code: int, filename: os.PathLike):
- """Once the zip download is finished, unzip it into the correct location."""
- self.zip_complete = True
- if response_code != 200:
- self.failure.emit(
- self.repo,
- translate(
- "AddonsInstaller",
- "Error: Error while downloading ZIP file for {}",
- ).format(self.repo.display_name),
- )
- return
-
- with zipfile.ZipFile(filename, "r") as zfile:
- master = zfile.namelist()[0] # github will put everything in a subfolder
- self.status_message.emit(
- translate("AddonsInstaller", "Download complete. Unzipping file...")
- )
- QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents)
- zfile.extractall(self.zipdir)
- for extracted_filename in os.listdir(self.zipdir + os.sep + master):
- shutil.move(
- self.zipdir + os.sep + master + os.sep + extracted_filename,
- self.zipdir + os.sep + extracted_filename,
- )
- os.rmdir(self.zipdir + os.sep + master)
- if self.bakdir:
- shutil.rmtree(self.bakdir)
- self.update_metadata()
- self.success.emit(
- self.repo,
- translate(
- "AddonsInstaller",
- "Successfully installed {} from ZIP file",
- ).format(self.repo.display_name),
- )
-
- def update_metadata(self):
- """Loads the package metadata from the Addon's downloaded package.xml file."""
- basedir = FreeCAD.getUserAppDataDir()
- package_xml = os.path.join(basedir, "Mod", self.repo.name, "package.xml")
- if os.path.isfile(package_xml):
- self.repo.load_metadata_file(package_xml)
- self.repo.installed_version = self.repo.metadata.Version
- self.repo.updated_timestamp = os.path.getmtime(package_xml)
-
-
-class DependencyInstallationWorker(QtCore.QThread):
- """Install dependencies using Addonmanager for FreeCAD, and pip for python"""
-
- no_python_exe = QtCore.Signal()
- no_pip = QtCore.Signal(str) # Attempted command
- failure = QtCore.Signal(str, str) # Short message, detailed message
- success = QtCore.Signal()
-
- def __init__(
- self,
- addons: List[Addon],
- python_requires: List[str],
- python_optional: List[str],
- location: os.PathLike = None,
- ):
- """Install the various types of dependencies that might be specified. If an optional
- dependency fails this is non-fatal, but other failures are considered fatal. If location
- is specified it overrides the FreeCAD user base directory setting: this is used mostly
- for testing purposes and shouldn't be set by normal code in most circumstances."""
- QtCore.QThread.__init__(self)
- self.addons = addons
- self.python_requires = python_requires
- self.python_optional = python_optional
- self.location = location
-
- def run(self):
- """Normally not called directly: create the object and call start() to launch it
- in its own thread. Installs dependencies for the Addon."""
- self._install_required_addons()
- if self.python_requires or self.python_optional:
- self._install_python_packages()
- self.success.emit()
-
- def _install_required_addons(self):
- """Install whatever FreeCAD Addons were set as required."""
- for repo in self.addons:
- if QtCore.QThread.currentThread().isInterruptionRequested():
- return
- location = self.location
- if location:
- location = os.path.join(location, "Mod")
- worker = InstallWorkbenchWorker(repo, location=location)
- worker.start()
- while worker.isRunning():
- if QtCore.QThread.currentThread().isInterruptionRequested():
- worker.requestInterruption()
- worker.wait()
- return
- time.sleep(0.1)
- QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 50)
-
- def _install_python_packages(self):
- """Install required and optional Python dependencies using pip."""
- if not self._verify_pip():
- return
-
- if self.location:
- vendor_path = os.path.join(self.location, "AdditionalPythonPackages")
- else:
- vendor_path = utils.get_pip_target_directory()
- if not os.path.exists(vendor_path):
- os.makedirs(vendor_path)
-
- self._install_required(vendor_path)
- self._install_optional(vendor_path)
-
- def _verify_pip(self) -> bool:
- """Ensure that pip is working -- returns True if it is, or False if not. Also emits the
- no_pip signal if pip cannot execute."""
- python_exe = utils.get_python_exe()
- pip_failed = False
- if python_exe:
- try:
- proc = subprocess.run(
- [python_exe, "-m", "pip", "--version"],
- stdout=subprocess.PIPE,
- timeout=30,
- check=True,
- )
- except subprocess.CalledProcessError:
- pip_failed = True
- else:
- pip_failed = True
- if pip_failed:
- self.no_pip.emit(f"{python_exe} -m pip --version")
- if proc:
- FreeCAD.Console.PrintMessage(proc.stdout)
- FreeCAD.Console.PrintWarning(proc.stderr)
- result = proc.stdout
- FreeCAD.Console.PrintMessage(result.decode())
- return not pip_failed
-
- def _install_required(self, vendor_path: os.PathLike):
- """Install the required Python package dependencies. If any fail a failure signal is
- emitted and the function exits without proceeding with any additional installs."""
- python_exe = utils.get_python_exe()
- for pymod in self.python_requires:
- if QtCore.QThread.currentThread().isInterruptionRequested():
- return
- try:
- proc = subprocess.run(
- [
- python_exe,
- "-m",
- "pip",
- "install",
- "--disable-pip-version-check",
- "--target",
- vendor_path,
- pymod,
- ],
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- timeout=30,
- check=True,
- )
- except subprocess.CalledProcessError as e:
- FreeCAD.Console.PrintError(str(e))
- self.failure.emit(
- translate(
- "AddonsInstaller",
- "Installation of Python package {} failed",
- ).format(pymod),
- str(e),
- )
- return
- if proc:
- FreeCAD.Console.PrintMessage(proc.stdout.decode())
-
- def _install_optional(self, vendor_path: os.PathLike):
- """Install the optional Python package dependencies. If any fail a message is printed to
- the console, but installation of the others continues."""
- python_exe = utils.get_python_exe()
- for pymod in self.python_optional:
- if QtCore.QThread.currentThread().isInterruptionRequested():
- return
- try:
- proc = subprocess.run(
- [
- python_exe,
- "-m",
- "pip",
- "install",
- "--target",
- vendor_path,
- pymod,
- ],
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- timeout=30,
- check=True,
- )
- except subprocess.CalledProcessError as e:
- FreeCAD.Console.PrintError(str(e))
- continue
- FreeCAD.Console.PrintMessage(proc.stdout.decode())
- if proc.returncode != 0:
- FreeCAD.Console.PrintError(proc.stderr.decode())
-
-
class UpdateMetadataCacheWorker(QtCore.QThread):
"""Scan through all available packages and see if our local copy of package.xml needs to be
updated"""
@@ -781,159 +304,4 @@ class UpdateMetadataCacheWorker(QtCore.QThread):
repo.cached_icon_filename = cache_file
-class UpdateAllWorker(QtCore.QThread):
- """Update all listed packages, of any kind"""
-
- progress_made = QtCore.Signal(int, int)
- status_message = QtCore.Signal(str)
- success = QtCore.Signal(Addon)
- failure = QtCore.Signal(Addon)
-
- # TODO: This should be re-written to be solidly single-threaded, some of the called code is
- # not re-entrant
-
- def __init__(self, repos):
- super().__init__()
- self.repos = repos
- self.repo_queue = None
-
- def run(self):
- """Normally not called directly: create the object and call start() to launch it
- in its own thread."""
- self.progress_made.emit(0, len(self.repos))
- self.repo_queue = queue.Queue()
- current_thread = QtCore.QThread.currentThread()
- for repo in self.repos:
- self.repo_queue.put(repo)
- FreeCAD.Console.PrintLog(
- f" UPDATER: Adding '{repo.name}' to update queue\n"
- )
-
- # The original design called for multiple update threads at the same time, but the updater
- # itself is not thread-safe, so for the time being only spawn one update thread.
- workers = []
- for _ in range(1):
- FreeCAD.Console.PrintLog(" UPDATER: Starting worker\n")
- worker = UpdateSingleWorker(self.repo_queue)
- worker.success.connect(self.on_success)
- worker.failure.connect(self.on_failure)
- worker.start()
- workers.append(worker)
-
- while not self.repo_queue.empty():
- if current_thread.isInterruptionRequested():
- for worker in workers:
- worker.blockSignals(True)
- worker.requestInterruption()
- worker.wait()
- return
- # Ensure our signals propagate out by running an internal thread-local event loop
- QtCore.QCoreApplication.processEvents()
-
- self.repo_queue.join()
-
- # Make sure all of our child threads have fully exited:
- for worker in workers:
- worker.wait()
-
- def on_success(self, repo: Addon) -> None:
- """Callback for a successful update"""
- FreeCAD.Console.PrintLog(
- f" UPDATER: Main thread received notice that worker successfully updated {repo.name}\n"
- )
- self.progress_made.emit(
- len(self.repos) - self.repo_queue.qsize(), len(self.repos)
- )
- self.success.emit(repo)
-
- def on_failure(self, repo: Addon) -> None:
- """Callback when an update failed"""
- FreeCAD.Console.PrintLog(
- f" UPDATER: Main thread received notice that worker failed to update {repo.name}\n"
- )
- self.progress_made.emit(
- len(self.repos) - self.repo_queue.qsize(), len(self.repos)
- )
- self.failure.emit(repo)
-
-
-class UpdateSingleWorker(QtCore.QThread):
- """Worker class to update a single Addon"""
-
- success = QtCore.Signal(Addon)
- failure = QtCore.Signal(Addon)
-
- def __init__(self, repo_queue: queue.Queue, location=None):
- super().__init__()
- self.repo_queue = repo_queue
- self.location = location
-
- def run(self):
- """Not usually called directly: instead, create an instance and call its
- start() function to spawn a new thread."""
- current_thread = QtCore.QThread.currentThread()
- while True:
- if current_thread.isInterruptionRequested():
- FreeCAD.Console.PrintLog(
- " UPDATER: Interruption requested, stopping all updates\n"
- )
- return
- try:
- repo = self.repo_queue.get_nowait()
- FreeCAD.Console.PrintLog(
- f" UPDATER: Pulling {repo.name} from the update queue\n"
- )
- except queue.Empty:
- FreeCAD.Console.PrintLog(
- " UPDATER: Worker thread queue is empty, exiting thread\n"
- )
- return
- if repo.repo_type == Addon.Kind.MACRO:
- FreeCAD.Console.PrintLog(f" UPDATER: Updating macro '{repo.name}'\n")
- self.update_macro(repo)
- else:
- FreeCAD.Console.PrintLog(f" UPDATER: Updating addon '{repo.name}'\n")
- self.update_package(repo)
- self.repo_queue.task_done()
- FreeCAD.Console.PrintLog(
- f" UPDATER: Worker thread completed action for '{repo.name}' and reported result "
- + "to main thread\n"
- )
-
- def update_macro(self, repo: Addon):
- """Updating a macro happens in this function, in the current thread"""
-
- cache_path = os.path.join(
- FreeCAD.getUserCachePath(), "AddonManager", "MacroCache"
- )
- os.makedirs(cache_path, exist_ok=True)
- install_succeeded, _ = repo.macro.install(cache_path)
-
- if install_succeeded:
- install_succeeded, _ = repo.macro.install(FreeCAD.getUserMacroDir(True))
- utils.update_macro_installation_details(repo)
-
- if install_succeeded:
- self.success.emit(repo)
- else:
- self.failure.emit(repo)
-
- def update_package(self, repo: Addon):
- """Updating a package re-uses the package installation worker, so actually spawns another
- thread that we block on"""
-
- worker = InstallWorkbenchWorker(repo, location=self.location)
- worker.success.connect(lambda repo, _: self.success.emit(repo))
- worker.failure.connect(lambda repo, _: self.failure.emit(repo))
- worker.start()
- while True:
- # Ensure our signals propagate out by running an internal thread-local event loop
- QtCore.QCoreApplication.processEvents()
- if not worker.isRunning():
- break
-
- time.sleep(0.1) # Give the signal a moment to propagate to the other threads
- QtCore.QCoreApplication.processEvents()
-
-
# @}
diff --git a/src/Mod/AddonManager/change_branch.py b/src/Mod/AddonManager/change_branch.py
index 5144531570..6182378ee4 100644
--- a/src/Mod/AddonManager/change_branch.py
+++ b/src/Mod/AddonManager/change_branch.py
@@ -1,5 +1,3 @@
-# -*- coding: utf-8 -*-
-
# ***************************************************************************
# * *
# * Copyright (c) 2022 FreeCAD Project Association *
diff --git a/src/Mod/AddonManager/manage_python_dependencies.py b/src/Mod/AddonManager/manage_python_dependencies.py
index b4eb37b171..3e46004131 100644
--- a/src/Mod/AddonManager/manage_python_dependencies.py
+++ b/src/Mod/AddonManager/manage_python_dependencies.py
@@ -94,39 +94,16 @@ def call_pip(args) -> List[str]:
call_args.extend(args)
proc = None
try:
- no_window_flag = 0
- if hasattr(subprocess, "CREATE_NO_WINDOW"):
- no_window_flag = subprocess.CREATE_NO_WINDOW # Added in Python 3.7
- proc = subprocess.run(
- call_args,
- stdout=subprocess.PIPE,
- stderr=subprocess.PIPE,
- check=True,
- timeout=30,
- creationflags=no_window_flag,
- )
- if proc.returncode != 0:
- pip_failed = True
- except subprocess.CalledProcessError as e:
- pip_failed = True
- print(e)
- except subprocess.TimeoutExpired:
- FreeCAD.Console.PrintWarning(
- translate(
- "AddonsInstaller",
- "pip took longer than 30 seconds to return results, giving up on it",
- )
- + "\n"
- )
- FreeCAD.Console.PrintLog(" ".join(call_args))
+ proc = utils.run_interruptable_subprocess(call_args)
+ except subprocess.CalledProcessError:
pip_failed = True
result = []
if not pip_failed:
- data = proc.stdout.decode()
+ data = proc.stdout
result = data.split("\n")
elif proc:
- raise PipFailed(proc.stderr.decode())
+ raise PipFailed(proc.stderr)
else:
raise PipFailed("pip timed out")
else:
diff --git a/src/Mod/AddonManager/package_details.py b/src/Mod/AddonManager/package_details.py
index 9f024d0e8f..42c6fa7c90 100644
--- a/src/Mod/AddonManager/package_details.py
+++ b/src/Mod/AddonManager/package_details.py
@@ -1,5 +1,3 @@
-# -*- coding: utf-8 -*-
-
# ***************************************************************************
# * *
# * Copyright (c) 2022 FreeCAD Project Association *
diff --git a/src/Mod/AddonManager/package_list.py b/src/Mod/AddonManager/package_list.py
index 0d8944958b..ff63172cea 100644
--- a/src/Mod/AddonManager/package_list.py
+++ b/src/Mod/AddonManager/package_list.py
@@ -1,5 +1,3 @@
-# -*- coding: utf-8 -*-
-
# ***************************************************************************
# * *
# * Copyright (c) 2022 FreeCAD Project Association *
diff --git a/src/Mod/AddonManager/update_all.ui b/src/Mod/AddonManager/update_all.ui
new file mode 100644
index 0000000000..94d2a19c81
--- /dev/null
+++ b/src/Mod/AddonManager/update_all.ui
@@ -0,0 +1,105 @@
+
+
+ UpdateAllDialog
+
+
+
+ 0
+ 0
+ 400
+ 300
+
+
+
+ Updating Addons
+
+
+ true
+
+
+ -
+
+
+ Updating out-of-date addons...
+
+
+
+ -
+
+
+ QAbstractItemView::NoEditTriggers
+
+
+ false
+
+
+ false
+
+
+ false
+
+
+ QAbstractItemView::NoSelection
+
+
+ false
+
+
+ false
+
+
+ true
+
+
+ false
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QDialogButtonBox::Cancel
+
+
+
+
+
+
+
+
+ buttonBox
+ accepted()
+ UpdateAllDialog
+ accept()
+
+
+ 248
+ 254
+
+
+ 157
+ 274
+
+
+
+
+ buttonBox
+ rejected()
+ UpdateAllDialog
+ reject()
+
+
+ 316
+ 260
+
+
+ 286
+ 274
+
+
+
+
+