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:
Chris Hennes
2022-11-18 18:51:04 -06:00
parent 403e0dc477
commit 89c191e160
46 changed files with 4012 additions and 1666 deletions

View File

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

View File

@@ -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"
)
# @}

View File

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

View File

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

View File

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

View File

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

View 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"))
)

View File

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

View File

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

View 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/>. *
# * *
# ***************************************************************************

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

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# FreeCAD init script of the AddonManager module
# (c) 2001 Juergen Riegel
# License LGPL

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# AddonManager gui init module
# (c) 2001 Juergen Riegel
# License LGPL

View File

@@ -1,5 +1,3 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * *
# * Copyright (c) 2022 FreeCAD Project Association *

View File

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

View 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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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"
)

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

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * *
# * Copyright (c) 2018 Gaël Écorchard <galou_breizh@yahoo.fr> *

View 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

View File

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

View File

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

View File

@@ -1,5 +1,3 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * *
# * Copyright (c) 2022 FreeCAD Project Association *

View File

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

View File

@@ -1,5 +1,3 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * *
# * Copyright (c) 2022 FreeCAD Project Association *

View File

@@ -1,5 +1,3 @@
# -*- coding: utf-8 -*-
# ***************************************************************************
# * *
# * Copyright (c) 2022 FreeCAD Project Association *

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