From c1d86b2f0b0d6b2644ea6724e713cdb0483bb02a Mon Sep 17 00:00:00 2001 From: Chris Hennes Date: Wed, 9 Mar 2022 23:14:59 -0600 Subject: [PATCH] Addon Manager: Refactor to improve testability --- src/Mod/AddonManager/AddonManager.py | 404 ++++++++++++------------ src/Mod/AddonManager/package_details.py | 2 + 2 files changed, 206 insertions(+), 200 deletions(-) diff --git a/src/Mod/AddonManager/AddonManager.py b/src/Mod/AddonManager/AddonManager.py index f878d45f3b..01d2ec90e6 100644 --- a/src/Mod/AddonManager/AddonManager.py +++ b/src/Mod/AddonManager/AddonManager.py @@ -265,6 +265,7 @@ class CommandAddonManager: self.packages_with_updates = set() self.startup_sequence = [] self.cleanup_workers() + self.determine_cache_update_status() # restore window geometry from stored state pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") @@ -272,77 +273,6 @@ class CommandAddonManager: h = pref.GetInt("WindowHeight", 600) self.dialog.resize(w, h) - # figure out our cache update frequency: there is a combo box in the preferences dialog with three - # options: never, daily, and weekly. Check that first, but allow it to be overridden by a more specific - # DaysBetweenUpdates selection, if the user has provided it. For that parameter we use: - # -1: Only manual updates (default) - # 0: Update every launch - # >0: Update every n days - self.update_cache = False - if hasattr(self, "trigger_recache") and self.trigger_recache: - self.update_cache = True - update_frequency = pref.GetInt("UpdateFrequencyComboEntry", 0) - if update_frequency == 0: - days_between_updates = -1 - elif update_frequency == 1: - days_between_updates = 1 - elif update_frequency == 2: - days_between_updates = 7 - days_between_updates = pref.GetInt("DaysBetweenUpdates", days_between_updates) - last_cache_update_string = pref.GetString("LastCacheUpdate", "never") - cache_path = FreeCAD.getUserCachePath() - am_path = os.path.join(cache_path, "AddonManager") - if last_cache_update_string == "never": - self.update_cache = True - elif days_between_updates > 0: - if hasattr(date, "fromisoformat"): - last_cache_update = date.fromisoformat(last_cache_update_string) - else: - # Python 3.6 and earlier don't have date.fromisoformat - date_re = re.compile( - "([0-9]{4})-?(1[0-2]|0[1-9])-?(3[01]|0[1-9]|[12][0-9])" - ) - matches = date_re.match(last_cache_update_string) - last_cache_update = date( - int(matches.group(1)), int(matches.group(2)), int(matches.group(3)) - ) - delta_update = timedelta(days=days_between_updates) - if date.today() >= last_cache_update + delta_update: - self.update_cache = True - elif days_between_updates == 0: - self.update_cache = True - elif not os.path.isdir(am_path): - self.update_cache = True - stopfile = self.get_cache_file_name("CACHE_UPDATE_INTERRUPTED") - if os.path.exists(stopfile): - self.update_cache = True - os.remove(stopfile) - FreeCAD.Console.PrintMessage( - translate( - "AddonsInstaller", - "Previous cache process was interrupted, restarting...\n", - ) - ) - - # See if the user has changed the custom repos list since our last re-cache: - stored_hash = pref.GetString("CustomRepoHash", "") - custom_repos = pref.GetString("CustomRepositories", "") - if custom_repos: - hasher = hashlib.sha1() - hasher.update(custom_repos.encode("utf-8")) - new_hash = hasher.hexdigest() - else: - new_hash = "" - if new_hash != stored_hash: - stored_hash = pref.SetString("CustomRepoHash", new_hash) - self.update_cache = True - FreeCAD.Console.PrintMessage( - translate( - "AddonsInstaller", - "Custom repo list changed, forcing recache...\n", - ) - ) - # If we are checking for updates automatically, hide the Check for updates button: autocheck = pref.GetBool("AutoCheck", False) if autocheck: @@ -453,13 +383,81 @@ class CommandAddonManager: ).format(worker) ) - def wait_on_other_workers(self) -> None: - for worker in self.workers: - if hasattr(self, worker): - thread = getattr(self, worker) - if thread: - if not thread.isFinished(): - thread.wait() + def determine_cache_update_status(self) -> None: + """Determine whether we need to update the cache, based on user preference, and previous + cache update status. Sets self.update_cache to either True or False.""" + + # Figure out our cache update frequency: there is a combo box in the preferences dialog with three + # options: never, daily, and weekly. Check that first, but allow it to be overridden by a more specific + # DaysBetweenUpdates selection, if the user has provided it. For that parameter we use: + # -1: Only manual updates (default) + # 0: Update every launch + # >0: Update every n days + pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") + self.update_cache = False + if hasattr(self, "trigger_recache") and self.trigger_recache: + self.update_cache = True + update_frequency = pref.GetInt("UpdateFrequencyComboEntry", 0) + if update_frequency == 0: + days_between_updates = -1 + elif update_frequency == 1: + days_between_updates = 1 + elif update_frequency == 2: + days_between_updates = 7 + days_between_updates = pref.GetInt("DaysBetweenUpdates", days_between_updates) + last_cache_update_string = pref.GetString("LastCacheUpdate", "never") + cache_path = FreeCAD.getUserCachePath() + am_path = os.path.join(cache_path, "AddonManager") + if last_cache_update_string == "never": + self.update_cache = True + elif days_between_updates > 0: + if hasattr(date, "fromisoformat"): + last_cache_update = date.fromisoformat(last_cache_update_string) + else: + # Python 3.6 and earlier don't have date.fromisoformat + date_re = re.compile( + "([0-9]{4})-?(1[0-2]|0[1-9])-?(3[01]|0[1-9]|[12][0-9])" + ) + matches = date_re.match(last_cache_update_string) + last_cache_update = date( + int(matches.group(1)), int(matches.group(2)), int(matches.group(3)) + ) + delta_update = timedelta(days=days_between_updates) + if date.today() >= last_cache_update + delta_update: + self.update_cache = True + elif days_between_updates == 0: + self.update_cache = True + elif not os.path.isdir(am_path): + self.update_cache = True + stopfile = self.get_cache_file_name("CACHE_UPDATE_INTERRUPTED") + if os.path.exists(stopfile): + self.update_cache = True + os.remove(stopfile) + FreeCAD.Console.PrintMessage( + translate( + "AddonsInstaller", + "Previous cache process was interrupted, restarting...\n", + ) + ) + + # See if the user has changed the custom repos list since our last re-cache: + stored_hash = pref.GetString("CustomRepoHash", "") + custom_repos = pref.GetString("CustomRepositories", "") + if custom_repos: + hasher = hashlib.sha1() + hasher.update(custom_repos.encode("utf-8")) + new_hash = hasher.hexdigest() + else: + new_hash = "" + if new_hash != stored_hash: + stored_hash = pref.SetString("CustomRepoHash", new_hash) + self.update_cache = True + FreeCAD.Console.PrintMessage( + translate( + "AddonsInstaller", + "Custom repo list changed, forcing recache...\n", + ) + ) def reject(self) -> None: """called when the window has been closed""" @@ -831,14 +829,12 @@ class CommandAddonManager: addon_repo.icon = self.get_icon(addon_repo) for repo in self.item_model.repos: if repo.name == addon_repo.name: - # FreeCAD.Console.PrintLog( - # f"Possible duplicate addon: ignoring second addition of {addon_repo.name}\n" - # ) return self.item_model.append_item(addon_repo) def get_icon(self, repo: Addon, update: bool = False) -> QtGui.QIcon: - """returns an icon for a repo""" + """Returns an icon for an Addon. Uses a cached icon if possible, unless update is True, + in which case the icon is regenerated.""" if not update and repo.icon and not repo.icon.isNull() and repo.icon.isValid(): return repo.icon @@ -917,65 +913,78 @@ class CommandAddonManager: self.item_model.append_item(repo) - def resolve_dependencies(self, repo: Addon) -> None: - if not repo: - return + # @dataclass(frozen) + class MissingDependencies(): + """Encapsulates a group of four types of dependencies: + * Internal workbenches -> wbs + * External addons -> external_addons + * Required Python packages -> python_required + * Optional Python packages -> python_optional + """ - deps = Addon.Dependencies() - repo_name_dict = dict() - for r in self.item_model.repos: - repo_name_dict[repo.name] = r - repo_name_dict[repo.display_name] = r - repo.walk_dependency_tree(repo_name_dict, deps) + def __init__(self, repo: Addon, all_repos: List[Addon]): - FreeCAD.Console.PrintLog("The following Workbenches are required:\n") - for addon in deps.internal_workbenches: - FreeCAD.Console.PrintLog(addon + "\n") + deps = Addon.Dependencies() + repo_name_dict = dict() + for r in all_repos: + repo_name_dict[repo.name] = r + repo_name_dict[repo.display_name] = r + repo.walk_dependency_tree(repo_name_dict, deps) - FreeCAD.Console.PrintLog("The following addons are required:\n") - for addon in deps.required_external_addons: - FreeCAD.Console.PrintLog(addon.name + "\n") + self.external_addons = [] + for dep in deps.required_external_addons: + if dep.status() == Addon.Status.NOT_INSTALLED: + self.external_addons.append(dep) - FreeCAD.Console.PrintLog("The following Python modules are required:\n") - for pyreq in deps.python_required: - FreeCAD.Console.PrintLog(pyreq + "\n") + # Now check the loaded addons to see if we are missing an internal workbench: + wbs = [wb.lower() for wb in FreeCADGui.listWorkbenches()] - FreeCAD.Console.PrintLog("The following Python modules are optional:\n") - for pyreq in deps.python_optional: - FreeCAD.Console.PrintLog(pyreq + "\n") + self.wbs = [] + for dep in deps.internal_workbenches: + if dep.lower() + "workbench" not in wbs: + if dep == "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) + else: + self.wbs.append(dep) - missing_external_addons = [] - for dep in deps.required_external_addons: - if dep.status() == Addon.Status.NOT_INSTALLED: - missing_external_addons.append(dep) - - # Now check the loaded addons to see if we are missing an internal workbench: - wbs = [wb.lower() for wb in FreeCADGui.listWorkbenches()] - - missing_wbs = [] - for dep in deps.internal_workbenches: - if dep.lower() + "workbench" not in wbs: - if dep == "Plot": - # Special case for plot, which is no longer a full workbench: + # Check the Python dependencies: + self.python_required = [] + for py_dep in deps.python_required: + if py_dep not in self.python_required: try: - __import__("Plot") + __import__(py_dep) except ImportError: - # Plot might fail for a number of reasons - missing_wbs.append(dep) - else: - missing_wbs.append(dep) + self.python_required.append(py_dep) - # Check the Python dependencies: - missing_python_requirements = [] - for py_dep in deps.python_required: - if py_dep not in missing_python_requirements: + self.python_optional = [] + for py_dep in deps.python_optional: try: __import__(py_dep) except ImportError: - missing_python_requirements.append(py_dep) + self.python_optional.append(py_dep) + + self.wbs.sort() + self.external_addons.sort() + self.python_required.sort() + self.python_optional.sort() + self.python_optional = [ + option + for option in self.python_optional + if option not in self.python_required + ] + + def handle_disallowed_python(self, python_required: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 missing_python_requirements: + for dep in python_required: if dep not in self.allowed_packages: bad_packages.append(dep) @@ -1004,85 +1013,80 @@ class CommandAddonManager: ) for package in self.allowed_packages: FreeCAD.Console.PrintMessage(f" * {package}\n") + return True + else: + return False + + def report_missing_workbenches(self, addon_name:str, wbs) -> None: + if len(wbs) == 1: + name = wbs[0] + message = translate( + "AddonsInstaller", + "Addon '{}' requires '{}', which is not available in your copy of FreeCAD.", + ).format(addon_name, name) + else: + message = "

" + translate( + "AddonsInstaller", + "Addon '{}' requires the following workbenches, which are not available in your copy of FreeCAD:", + ).format(addon_name) + "

" + QtWidgets.QMessageBox.critical( + self.dialog, + translate("AddonsInstaller", "Missing Requirement"), + message, + QtWidgets.QMessageBox.Cancel, + ) + + def display_dep_resolution_dialog(self, missing) -> None: + self.dependency_dialog = FreeCADGui.PySideUic.loadUi( + os.path.join( + os.path.dirname(__file__), "dependency_resolution_dialog.ui" + ) + ) + + for addon in missing.external_addons: + self.dependency_dialog.listWidgetAddons.addItem(addon) + for mod in missing.python_required: + 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(lambda: self.dependency_dialog_yes_clicked(repo)) + self.dependency_dialog.buttonBox.button( + QtWidgets.QDialogButtonBox.Ignore + ).clicked.connect(lambda: 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_python_optionals = [] - for py_dep in deps.python_optional: - try: - __import__(py_dep) - except ImportError: - missing_python_optionals.append(py_dep) + missing = CommandAddonManager.MissingDependencies(repo, self.item_model.repos) + if self.handle_disallowed_python(missing.python_required): + return - # Possible cases - # 1) Missing required FreeCAD workbenches. Unrecoverable failure, needs a new version of FreeCAD installation. - # 2) Missing required external AddOn(s). List for the user and ask for permission to install them. - # 3) Missing required Python modules. List for the user and ask for permission to attempt installation. - # 4) Missing optional Python modules. User can choose from the list to attempt to install any or all. - # Option 1 is standalone, and simply causes failure to install. Other options can be combined and are - # presented through a dialog box with options. - - addon = repo.display_name if repo.display_name else repo.name - if missing_wbs: - if len(missing_wbs) == 1: - name = missing_wbs[0] - message = translate( - "AddonsInstaller", - "Addon '{}' requires '{}', which is not available in your copy of FreeCAD.", - ).format(addon, name) - else: - message = "

" + translate( - "AddonsInstaller", - "Addon '{}' requires the following workbenches, which are not available in your copy of FreeCAD:", - ).format(addon) + "

" - QtWidgets.QMessageBox.critical( - self.dialog, - translate("AddonsInstaller", "Missing Requirement"), - message, - QtWidgets.QMessageBox.Cancel, - ) + if missing.wbs: + # Unrecoverable failure, needs a new version of FreeCAD installation + self.report_missing_workbenches(repo.display_name, missing.wbs) elif ( - missing_external_addons - or missing_python_requirements - or missing_python_optionals + missing.external_addons + or missing.python_required + or missing.python_optional ): - self.dependency_dialog = FreeCADGui.PySideUic.loadUi( - os.path.join( - os.path.dirname(__file__), "dependency_resolution_dialog.ui" - ) - ) - missing_external_addons.sort() - missing_python_requirements.sort() - missing_python_optionals.sort() - missing_python_optionals = [ - option - for option in missing_python_optionals - if option not in missing_python_requirements - ] - - for addon in missing_external_addons: - self.dependency_dialog.listWidgetAddons.addItem(addon) - for mod in missing_python_requirements: - self.dependency_dialog.listWidgetPythonRequired.addItem(mod) - for mod in missing_python_optionals: - 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(lambda: self.dependency_dialog_yes_clicked(repo)) - self.dependency_dialog.buttonBox.button( - QtWidgets.QDialogButtonBox.Ignore - ).clicked.connect(lambda: self.dependency_dialog_ignore_clicked(repo)) - self.dependency_dialog.buttonBox.button( - QtWidgets.QDialogButtonBox.Cancel - ).setDefault(True) - self.dependency_dialog.exec() + # Recoverable: ask the user if they want to install the missing deps + self.display_dep_resolution_dialog(missing) else: + # No missing deps, just install self.install(repo) def dependency_dialog_yes_clicked(self, repo: Addon) -> None: diff --git a/src/Mod/AddonManager/package_details.py b/src/Mod/AddonManager/package_details.py index 79b3bf6204..99534e3ca1 100644 --- a/src/Mod/AddonManager/package_details.py +++ b/src/Mod/AddonManager/package_details.py @@ -609,6 +609,7 @@ class PackageDetails(QWidget): def enable_clicked(self) -> None: self.repo.enable() + self.repo.set_status(Addon.Status.PENDING_RESTART) self.set_disable_button_state() self.update_status.emit(self.repo) self.ui.labelWarningInfo.show() @@ -624,6 +625,7 @@ class PackageDetails(QWidget): def disable_clicked(self) -> None: self.repo.disable() + self.repo.set_status(Addon.Status.PENDING_RESTART) self.set_disable_button_state() self.update_status.emit(self.repo) self.ui.labelWarningInfo.show()