Addon Manager: Refactor installation code
Improve testability of installation code by refactoring it to completely separate the GUI and non-GUI code, and to provide more robust support for non-GUI access to some type of Addon Manager activity.
This commit is contained in:
@@ -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
|
||||
]
|
||||
|
||||
@@ -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 = (
|
||||
"<p>"
|
||||
+ 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:",
|
||||
)
|
||||
+ "</p><ul>"
|
||||
)
|
||||
if len(bad_packages) < 15:
|
||||
for dep in bad_packages:
|
||||
message += f"<li>{dep}</li>"
|
||||
else:
|
||||
message += (
|
||||
"<li>("
|
||||
+ translate("AddonsInstaller", "Too many to list")
|
||||
+ ")</li>"
|
||||
)
|
||||
message += "</ul>"
|
||||
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 = (
|
||||
"<p>"
|
||||
+ translate(
|
||||
"AddonsInstaller",
|
||||
"Addon '{}' requires the following workbenches, which are not available in your copy of FreeCAD:",
|
||||
).format(addon_name)
|
||||
+ "</p><ul>"
|
||||
)
|
||||
for wb in wbs:
|
||||
message += "<li>" + wb + "</li>"
|
||||
message += "</ul>"
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
# @}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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 *
|
||||
# * <https://www.gnu.org/licenses/>. *
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
|
||||
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
|
||||
@@ -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:
|
||||
|
||||
386
src/Mod/AddonManager/AddonManagerTest/app/test_installer.py
Normal file
386
src/Mod/AddonManager/AddonManagerTest/app/test_installer.py
Normal file
@@ -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 *
|
||||
# * <https://www.gnu.org/licenses/>. *
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
|
||||
"""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"))
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
self.assertEqual(version, "", "Missing version did not yield empty string")
|
||||
|
||||
Binary file not shown.
Binary file not shown.
BIN
src/Mod/AddonManager/AddonManagerTest/data/test_simple_repo.zip
Normal file
BIN
src/Mod/AddonManager/AddonManagerTest/data/test_simple_repo.zip
Normal file
Binary file not shown.
22
src/Mod/AddonManager/AddonManagerTest/gui/gui_mocks.py
Normal file
22
src/Mod/AddonManager/AddonManagerTest/gui/gui_mocks.py
Normal file
@@ -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 *
|
||||
# * <https://www.gnu.org/licenses/>. *
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
|
||||
750
src/Mod/AddonManager/AddonManagerTest/gui/test_installer_gui.py
Normal file
750
src/Mod/AddonManager/AddonManagerTest/gui/test_installer_gui.py
Normal file
@@ -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 *
|
||||
# * <https://www.gnu.org/licenses/>. *
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
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)
|
||||
246
src/Mod/AddonManager/AddonManagerTest/gui/test_update_all_gui.py
Normal file
246
src/Mod/AddonManager/AddonManagerTest/gui/test_update_all_gui.py
Normal file
@@ -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 *
|
||||
# * <https://www.gnu.org/licenses/>. *
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
|
||||
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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# FreeCAD init script of the AddonManager module
|
||||
# (c) 2001 Juergen Riegel
|
||||
# License LGPL
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# AddonManager gui init module
|
||||
# (c) 2001 Juergen Riegel
|
||||
# License LGPL
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# ***************************************************************************
|
||||
# * *
|
||||
# * Copyright (c) 2022 FreeCAD Project Association *
|
||||
|
||||
@@ -62,8 +62,8 @@
|
||||
<file>icons/WebTools_workbench_icon.svg</file>
|
||||
<file>icons/workfeature_workbench_icon.svg</file>
|
||||
<file>icons/yaml-workspace_workbench_icon.svg</file>
|
||||
<file>icons/expanded_view.svg</file>
|
||||
<file>icons/compact_view.svg</file>
|
||||
<file>icons/expanded_view.svg</file>
|
||||
<file>licenses/Apache-2.0.txt</file>
|
||||
<file>licenses/BSD-2-Clause.txt</file>
|
||||
<file>licenses/BSD-3-Clause.txt</file>
|
||||
|
||||
@@ -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 *
|
||||
# * <https://www.gnu.org/licenses/>. *
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
|
||||
# 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
199
src/Mod/AddonManager/addonmanager_dependency_installer.py
Normal file
199
src/Mod/AddonManager/addonmanager_dependency_installer.py
Normal file
@@ -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 *
|
||||
# * <https://www.gnu.org/licenses/>. *
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
|
||||
"""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
|
||||
@@ -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(
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]:
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
512
src/Mod/AddonManager/addonmanager_installer.py
Normal file
512
src/Mod/AddonManager/addonmanager_installer.py
Normal file
@@ -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 *
|
||||
# * <https://www.gnu.org/licenses/>. *
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
|
||||
""" 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"
|
||||
)
|
||||
831
src/Mod/AddonManager/addonmanager_installer_gui.py
Normal file
831
src/Mod/AddonManager/addonmanager_installer_gui.py
Normal file
@@ -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 *
|
||||
# * <https://www.gnu.org/licenses/>. *
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
|
||||
"""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 = (
|
||||
"<p>"
|
||||
+ 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:",
|
||||
)
|
||||
+ "</p><ul>"
|
||||
)
|
||||
if len(bad_packages) < 15:
|
||||
for dep in bad_packages:
|
||||
message += f"<li>{dep}</li>"
|
||||
else:
|
||||
message += (
|
||||
"<li>("
|
||||
+ translate("AddonsInstaller", "Too many to list")
|
||||
+ ")</li>"
|
||||
)
|
||||
message += "</ul>"
|
||||
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 = (
|
||||
"<p>"
|
||||
+ translate(
|
||||
"AddonsInstaller",
|
||||
"Addon '{}' requires the following workbenches, which are not available in your copy of FreeCAD:",
|
||||
).format(addon_name)
|
||||
+ "</p><ul>"
|
||||
)
|
||||
for wb in wbs:
|
||||
message += "<li>" + wb + "</li>"
|
||||
message += "</ul>"
|
||||
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"<b>{self.addon_to_install.display_name}</b>"
|
||||
if self.addon_to_install.macro.comment:
|
||||
tooltipText += f"<br/><p>{self.addon_to_install.macro.comment}</p>"
|
||||
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()
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# ***************************************************************************
|
||||
# * *
|
||||
# * Copyright (c) 2018 Gaël Écorchard <galou_breizh@yahoo.fr> *
|
||||
|
||||
202
src/Mod/AddonManager/addonmanager_update_all_gui.py
Normal file
202
src/Mod/AddonManager/addonmanager_update_all_gui.py
Normal file
@@ -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 *
|
||||
# * <https://www.gnu.org/licenses/>. *
|
||||
# * *
|
||||
# ***************************************************************************
|
||||
|
||||
"""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
|
||||
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# ***************************************************************************
|
||||
# * *
|
||||
# * Copyright (c) 2018 Gaël Écorchard <galou_breizh@yahoo.fr> *
|
||||
@@ -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)
|
||||
|
||||
@@ -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<b>" + f + "</b>"
|
||||
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()
|
||||
|
||||
|
||||
# @}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# ***************************************************************************
|
||||
# * *
|
||||
# * Copyright (c) 2022 FreeCAD Project Association *
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# ***************************************************************************
|
||||
# * *
|
||||
# * Copyright (c) 2022 FreeCAD Project Association *
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# ***************************************************************************
|
||||
# * *
|
||||
# * Copyright (c) 2022 FreeCAD Project Association *
|
||||
|
||||
105
src/Mod/AddonManager/update_all.ui
Normal file
105
src/Mod/AddonManager/update_all.ui
Normal file
@@ -0,0 +1,105 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>UpdateAllDialog</class>
|
||||
<widget class="QDialog" name="UpdateAllDialog">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>400</width>
|
||||
<height>300</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Updating Addons</string>
|
||||
</property>
|
||||
<property name="modal">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>Updating out-of-date addons...</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTableWidget" name="tableWidget">
|
||||
<property name="editTriggers">
|
||||
<set>QAbstractItemView::NoEditTriggers</set>
|
||||
</property>
|
||||
<property name="tabKeyNavigation">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="showDropIndicator" stdset="0">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="dragDropOverwriteMode">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<property name="selectionMode">
|
||||
<enum>QAbstractItemView::NoSelection</enum>
|
||||
</property>
|
||||
<property name="showGrid">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<attribute name="horizontalHeaderVisible">
|
||||
<bool>false</bool>
|
||||
</attribute>
|
||||
<attribute name="horizontalHeaderStretchLastSection">
|
||||
<bool>true</bool>
|
||||
</attribute>
|
||||
<attribute name="verticalHeaderVisible">
|
||||
<bool>false</bool>
|
||||
</attribute>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QDialogButtonBox" name="buttonBox">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="standardButtons">
|
||||
<set>QDialogButtonBox::Cancel</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection>
|
||||
<sender>buttonBox</sender>
|
||||
<signal>accepted()</signal>
|
||||
<receiver>UpdateAllDialog</receiver>
|
||||
<slot>accept()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>248</x>
|
||||
<y>254</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>157</x>
|
||||
<y>274</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>buttonBox</sender>
|
||||
<signal>rejected()</signal>
|
||||
<receiver>UpdateAllDialog</receiver>
|
||||
<slot>reject()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>316</x>
|
||||
<y>260</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>286</x>
|
||||
<y>274</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
</connections>
|
||||
</ui>
|
||||
Reference in New Issue
Block a user