From 8b055dcd58c1a2c9b611022d5c2483bb26602834 Mon Sep 17 00:00:00 2001 From: Chris Hennes Date: Sun, 17 Aug 2025 19:23:22 -0500 Subject: [PATCH] Add check for missing dependencies on startup --- Addon.py | 17 ++++++ AddonManager.py | 83 ++++++++++++++++++++------ addonmanager_installer_gui.py | 9 +-- addonmanager_preferences_defaults.json | 1 + addonmanager_workers_startup.py | 55 ++++++++++++++++- 5 files changed, 142 insertions(+), 23 deletions(-) diff --git a/Addon.py b/Addon.py index f1282f09..378daebf 100644 --- a/Addon.py +++ b/Addon.py @@ -767,6 +767,23 @@ def import_from_addon(self, repo: Addon, all_repos: List[Addon]): option for option in self.python_optional if option not in self.python_requires ] + def join(self, other: "MissingDependencies"): + """Join two sets of missing dependencies together""" + self.external_addons.extend( + [x for x in other.external_addons if x not in self.external_addons] + ) + self.wbs.extend([x for x in other.wbs if x not in self.wbs]) + self.python_requires.extend( + [x for x in other.python_requires if x not in self.python_requires] + ) + self.python_optional.extend( + [x for x in other.python_optional if x not in self.python_optional] + ) + self.python_min_version = max(self.python_min_version, other.python_min_version) + + # Clean up optional: + self.python_optional = [x for x in self.python_optional if x not in self.python_requires] + @staticmethod def package_is_installed(package_name: str) -> bool: """Check to see if a Python package is installed (i.e., if it can be imported). diff --git a/AddonManager.py b/AddonManager.py index 42a516c1..3b62fb2d 100644 --- a/AddonManager.py +++ b/AddonManager.py @@ -36,8 +36,13 @@ CheckWorkbenchesForUpdatesWorker, GetBasicAddonStatsWorker, GetAddonScoreWorker, + CheckForMissingDependenciesWorker, +) +from addonmanager_installer_gui import ( + AddonInstallerGUI, + MacroInstallerGUI, + AddonDependencyInstallerGUI, ) -from addonmanager_installer_gui import AddonInstallerGUI, MacroInstallerGUI from addonmanager_icon_utilities import get_icon_for_addon from addonmanager_uninstaller_gui import AddonUninstallerGUI from addonmanager_update_all_gui import UpdateAllGUI @@ -46,8 +51,9 @@ from composite_view import CompositeView from Widgets.addonmanager_widget_global_buttons import WidgetGlobalButtonBar from Widgets.addonmanager_widget_progress_bar import Progress +from Widgets.addonmanager_utility_dialogs import MessageDialog from package_list import PackageListItemModel -from Addon import Addon, cycle_to_sub_addon +from Addon import Addon, cycle_to_sub_addon, MissingDependencies from addonmanager_python_deps_gui import ( PythonPackageManagerGui, ) @@ -104,6 +110,7 @@ class CommandAddonManager(QtCore.QObject): "check_for_python_package_updates_worker", "get_basic_addon_stats_worker", "get_addon_score_worker", + "check_missing_dependencies_worker", ] lock = threading.Lock() @@ -139,13 +146,17 @@ def __init__(self): self.create_addon_list_worker = None self.get_addon_score_worker = None self.get_basic_addon_stats_worker = None + self.check_missing_dependencies_worker = None self.manage_python_packages_dialog = None + self.missing_dependency_installer = None # Set up the connection checker self.connection_checker = ConnectionCheckerGUI() self.connection_checker.connection_available.connect(self.launch) + self.missing_dependencies = MissingDependencies() + # Give other parts of the AM access to the current instance global INSTANCE INSTANCE = self @@ -338,7 +349,7 @@ def startup(self) -> None: self.populate_packages_table, self.activate_table_widgets, self.check_updates, - self.check_python_updates, + self.check_missing_dependencies, self.fetch_addon_stats, self.fetch_addon_score, self.select_addon, @@ -358,6 +369,7 @@ def do_next_startup_phase(self) -> None: else: self.hide_progress_widgets() self.composite_view.package_list.item_filter.invalidateFilter() + self.post_startup() def populate_packages_table(self) -> None: self.item_model.clear() @@ -451,21 +463,15 @@ def update_check_complete(self) -> None: self.enable_updates(len(self.packages_with_updates)) self.button_bar.check_for_updates.setEnabled(True) - def check_python_updates(self) -> None: - # TODO: Run the checker to see if we need to do any Python updates as well - - # Really, there are two different things to check here: first, run our normal dependency - # checker and display the dependency resolution dialog. This will handle addons that have - # disappeared/been uninstalled (but were required by other addons) as well as Python required - # and optional dependencies. The only catch is, if we ONLY have optional Python dependencies - # missing, we should ignore them. - - # Second, if this is a version of Python we've used before, do any of our Python libraries - # installed into the custom directory, or the venv, need to be updated? - - # To the user these are two quite different things, so their interface should reflect that. - - self.do_next_startup_phase() + def check_missing_dependencies(self) -> None: + """See if we have any missing dependencies""" + self.check_missing_dependencies_worker = CheckForMissingDependenciesWorker( + self.item_model.repos + ) + self.update_progress_bar(translate("AddonsInstaller", "Checking dependencies"), 0, 100) + self.check_missing_dependencies_worker.progress.connect(self.update_progress_bar) + self.check_missing_dependencies_worker.finished.connect(self.do_next_startup_phase) + self.check_missing_dependencies_worker.start() def show_python_updates_dialog(self) -> None: if not self.manage_python_packages_dialog: @@ -627,6 +633,47 @@ def update_progress_bar(self, message: str, current_value: int, max_value: int) ) self.composite_view.package_list.update_loading_progress(progress) + def post_startup(self) -> None: + """This is called after the startup sequence has completed""" + if self.check_missing_dependencies_worker: + deps: MissingDependencies = self.check_missing_dependencies_worker.missing_dependencies + if deps.wbs or deps.external_addons or deps.python_requires: + ignored_deps_string = fci.Preferences().get("ignored_missing_deps") + ignored_deps = ignored_deps_string.split(";") if ignored_deps_string else [] + + proceed = False + all_deps = set() + all_deps.update(deps.wbs) + all_deps.update(deps.external_addons) + all_deps.update(deps.python_requires) + for dep in all_deps: + if dep not in ignored_deps: + proceed = True + break + + if proceed: + self.missing_dependency_installer = AddonDependencyInstallerGUI([], deps) + self.missing_dependency_installer.dependency_dialog.label.setText( + translate( + "AddonsInstaller", + "Some installed addons are missing dependencies. Would you like to install them now?", + ) + ) + self.missing_dependency_installer.dependency_dialog.buttonBox.button( + QtWidgets.QDialogButtonBox.Ignore + ).clicked.connect(self.ignore_missing_dependencies) + self.missing_dependency_installer.run() + + def ignore_missing_dependencies(self): + old_deps_string = fci.Preferences().get("ignored_missing_deps") + old_deps = set(old_deps_string.split(";") if old_deps_string else []) + deps = self.check_missing_dependencies_worker.missing_dependencies + new_deps = old_deps.union(deps.wbs) + new_deps = new_deps.union(deps.external_addons) + new_deps = new_deps.union(deps.python_requires) + new_deps_string = ";".join(new_deps) + fci.Preferences().set("ignored_missing_deps", new_deps_string) + def stop_update(self) -> None: self.cleanup_workers() self.hide_progress_widgets() diff --git a/addonmanager_installer_gui.py b/addonmanager_installer_gui.py index 6ac257a1..1562aa06 100644 --- a/addonmanager_installer_gui.py +++ b/addonmanager_installer_gui.py @@ -419,6 +419,11 @@ def __init__(self, addons: List[Addon], deps: MissingDependencies): self.installer: AddonInstaller = None self.dependency_installer: DependencyInstaller = None + self.dependency_dialog = fci.loadUi( + os.path.join(os.path.dirname(__file__), "dependency_resolution_dialog.ui") + ) + self.dependency_dialog.setObjectName("AddonManager_DependencyResolutionDialog") + def shutdown(self): try: self._stop_thread(self.dependency_worker_thread) @@ -591,10 +596,6 @@ def _report_missing_workbenches(self) -> bool: def _resolve_dependencies_then_continue(self) -> None: """Ask the user how they want to handle dependencies, do that, then install.""" - self.dependency_dialog = fci.loadUi( - os.path.join(os.path.dirname(__file__), "dependency_resolution_dialog.ui") - ) - self.dependency_dialog.setObjectName("AddonManager_DependencyResolutionDialog") for addon in self.deps.external_addons: self.dependency_dialog.listWidgetAddons.addItem(addon) diff --git a/addonmanager_preferences_defaults.json b/addonmanager_preferences_defaults.json index ad21a7ef..4fb3e98f 100644 --- a/addonmanager_preferences_defaults.json +++ b/addonmanager_preferences_defaults.json @@ -28,6 +28,7 @@ "alwaysAskForToolbar": true, "dontShowAddMacroButtonDialog": false, "force_git_in_repos": "parts_library", + "ignored_missing_deps": "", "last_fetched_addon_catalog_cache_hash": "Cache never fetched, no hash available", "last_fetched_macro_cache_hash": "Cache never fetched, no hash available", "macro_cache_url": "https://addons.freecad.org/macro_cache.zip", diff --git a/addonmanager_workers_startup.py b/addonmanager_workers_startup.py index f31df8ab..8cebadfa 100644 --- a/addonmanager_workers_startup.py +++ b/addonmanager_workers_startup.py @@ -36,7 +36,7 @@ from addonmanager_installation_manifest import InstallationManifest from addonmanager_macro import Macro -from Addon import Addon +from Addon import Addon, MissingDependencies from AddonCatalog import AddonCatalog from AddonStats import AddonStats import NetworkManager @@ -638,3 +638,56 @@ def run(self): fci.Console.PrintLog( f"Failed to convert score value '{score}' to an integer for {addon.name}" ) + + +class CheckForMissingDependenciesWorker(QtCore.QThread): + """A worker class to examine installed addons and check for missing dependencies""" + + progress = QtCore.Signal(str, int, int) + + def __init__(self, addons: List[Addon], parent: QtCore.QObject = None): + super().__init__(parent) + self.addons = addons + self.missing_dependencies = MissingDependencies() + + def run(self): + self.progress.emit( + translate("AddonsInstaller", "Checking for missing dependencies"), + 0, + len(self.addons), + ) + + installed_addons = [ + addon for addon in self.addons if addon.status() != Addon.Status.NOT_INSTALLED + ] + counter = 0 + details = "" + for addon in installed_addons: + counter += 1 + self.progress.emit( + translate("AddonsInstaller", "Checking for missing dependencies"), + counter, + len(installed_addons), + ) + deps = MissingDependencies() + deps.import_from_addon(addon, self.addons) + if deps.wbs: + details += f"{addon.display_name} is missing workbenches {', '.join(deps.wbs)}\n" + if deps.external_addons: + details += ( + f"{addon.display_name} is missing addons {', '.join(deps.external_addons)}\n" + ) + if deps.python_requires: + details += f"{addon.display_name} is missing python packages {', '.join(deps.python_requires)}\n" + self.missing_dependencies.join(deps) + + md = self.missing_dependencies + message = "\nAddon Missing Dependency Analysis\n" + message += "---------------------------------\n" + message += f"Missing FreeCAD Workbenches: {len(md.wbs)}\n" + message += f"Missing addons: {len(md.external_addons)}\n" + message += f"Missing required Python packages: {len(md.python_requires)}\n" + message += f"Missing optional Python packages: {len(md.python_optional)}\n" + message += f"Minimum required Python version evaluated to {md.python_min_version}\n\n" + fci.Console.PrintMessage(message) + fci.Console.PrintLog(details)