From ca6e2b95e54099c4f756f367d8c7c201acbdcab5 Mon Sep 17 00:00:00 2001 From: Frank Martinez Date: Mon, 18 May 2026 21:36:20 -0500 Subject: [PATCH 1/5] Add command to install pypi packages by pip (cherry picked from commit ee4e6cbb7a15f832b8692f4a50a34b58e0c43340) --- InitGui.py | 3 + PythonDependencyUpdateDialog.ui | 29 +++- Resources/icons/add_pypi_package.svg | 220 +++++++++++++++++++++++++++ addonmanager_python_deps.py | 102 +++++++------ addonmanager_python_deps_commands.py | 75 +++++++++ addonmanager_python_deps_gui.py | 58 ++++--- 6 files changed, 409 insertions(+), 78 deletions(-) create mode 100644 Resources/icons/add_pypi_package.svg create mode 100644 addonmanager_python_deps_commands.py diff --git a/InitGui.py b/InitGui.py index 5e468662..b251b5e3 100644 --- a/InitGui.py +++ b/InitGui.py @@ -6,8 +6,11 @@ import os import AddonManager +from addonmanager_python_deps_commands import Std_AddonMgrPip cwd = os.path.dirname(AddonManager.__file__) FreeCADGui.addLanguagePath(os.path.join(cwd, "Resources", "translations")) FreeCADGui.addIconPath(os.path.join(cwd, "Resources", "icons")) FreeCADGui.addCommand("Std_AddonMgr", AddonManager.CommandAddonManager()) + +Std_AddonMgrPip.install() diff --git a/PythonDependencyUpdateDialog.ui b/PythonDependencyUpdateDialog.ui index 66d4a33d..46bab399 100644 --- a/PythonDependencyUpdateDialog.ui +++ b/PythonDependencyUpdateDialog.ui @@ -25,12 +25,12 @@ - + placeholder for path - - Qt::TextInteractionFlag::TextSelectableByMouse + + true @@ -48,12 +48,18 @@ - - - Update in progress… + + + 0 + + + 0 - - Qt::TextInteractionFlag::NoTextInteraction + + -1 + + + true @@ -76,6 +82,13 @@ + + + + Add Package + + + diff --git a/Resources/icons/add_pypi_package.svg b/Resources/icons/add_pypi_package.svg new file mode 100644 index 00000000..cdef240d --- /dev/null +++ b/Resources/icons/add_pypi_package.svg @@ -0,0 +1,220 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addonmanager_python_deps.py b/addonmanager_python_deps.py index a2bda8c7..e97953e9 100644 --- a/addonmanager_python_deps.py +++ b/addonmanager_python_deps.py @@ -29,6 +29,7 @@ import shutil import subprocess from typing import Dict, Iterable, List, TypedDict, Optional, Set +from enum import Enum from addonmanager_metadata import Version from addonmanager_utilities import ( create_pip_call, @@ -145,72 +146,68 @@ def parse_pip_list_output(all_packages, outdated_packages) -> List[PackageInfo]: return list(packages.values()) -class AsynchronousResetWorker(QtCore.QObject): - """A worker class that runs pip to generate the package list.""" +class PipCommand(Enum): + Install = 0 + Upgrade = 1 + List = 2 + +class AsynchronousPipWorker(QtCore.QObject): + """A worker class that runs pip to install/update/list packages.""" finished = QtCore.Signal() - def __init__(self, parent=None): + def __init__( + self, + command: PipCommand, + package_list: list[str] | None = None, + parent=None, + ) -> None: super().__init__(parent) self.is_running = False self.error = "" self.vendor_path = get_pip_target_directory() - self.package_list = [] + self.package_list = package_list or [] + self.command = command def run(self): """Runs pip: when complete, either self.package_list is populated, or self.error is set.""" self.is_running = True self.error = "" - self.package_list = [] - try: - outdated_packages_stdout = call_pip(["list", "-o", "--path", self.vendor_path]) - all_packages_stdout = call_pip(["list", "--path", self.vendor_path]) - self.package_list = parse_pip_list_output(all_packages_stdout, outdated_packages_stdout) - except PipFailed as e: - self.error = str(e) - self.is_running = False - self.finished.emit() - -class AsynchronousUpdateWorker(QtCore.QObject): - """A worker class that runs pip to generate the package list.""" + if self.command in (PipCommand.Upgrade, PipCommand.Install): + self._install_or_update() + self._list() - finished = QtCore.Signal() - - def __init__(self, parent=None): - super().__init__(parent) self.is_running = False - self.error = "" - self.vendor_path = get_pip_target_directory() - self.package_list = [] + self.finished.emit() - def run(self): - """Runs pip: when complete, either self.package_list is populated, or self.error is set.""" - self.is_running = True - self.error = "" + def _install_or_update(self) -> None: + if not self.package_list: + return update_string = " ".join(self.package_list) + action = "install" if self.command == PipCommand.Install else "upgrade" + log_message = f"Running pip to {action} the following packages in {self.vendor_path}: {update_string}\n" + upgrade = ["--upgrade"] if self.command == PipCommand.Upgrade else [] + command = ["install", *upgrade, "--target", self.vendor_path] + command.extend(self.package_list) + + fci.Console.PrintLog(f"{log_message}\n") try: - fci.Console.PrintLog( - f"Running pip to upgrade the following packages in {self.vendor_path}: {update_string}\n" - ) - command = ["install", "--upgrade", "--target", self.vendor_path] - command.extend(self.package_list) upgrade_stdout = call_pip(command) for line in upgrade_stdout: - fci.Console.PrintLog(line + "\n") + fci.Console.PrintLog(f"{line}\n") except PipFailed as e: self.error = str(e) - fci.Console.PrintError(self.error + "\n") + fci.Console.PrintError(f"{self.error}\n") + def _list(self) -> None: try: outdated_packages_stdout = call_pip(["list", "-o", "--path", self.vendor_path]) all_packages_stdout = call_pip(["list", "--path", self.vendor_path]) self.package_list = parse_pip_list_output(all_packages_stdout, outdated_packages_stdout) except PipFailed as e: self.error = str(e) - self.is_running = False - self.finished.emit() class PythonPackageListModel(QtCore.QAbstractTableModel): @@ -243,7 +240,7 @@ def reset_package_list(self): otherwise synchronous.""" self.beginResetModel() self.package_list.clear() - self.reset_worker = AsynchronousResetWorker() + self.reset_worker = AsynchronousPipWorker(PipCommand.List) if self.can_use_thread(): self.reset_worker_thread = QtCore.QThread() self.reset_worker.moveToThread(self.reset_worker_thread) @@ -304,7 +301,7 @@ def headerData(self, section, orientation, role=...) -> Optional[str]: elif section == 2: return translate("AddonsInstaller", "Available Version") elif section == 3: - return translate("AddonsInstaller", "Dependencies") + return translate("AddonsInstaller", "Dependent Addons") return None def flags(self, index) -> QtCore.Qt.ItemFlag: @@ -331,12 +328,20 @@ def get_dependent_addons(self, package) -> List[DependentAddon]: dependent_addons.append({"name": addon.name, "optional": True}) return dependent_addons - def update_all_packages(self): + def update_all_packages(self) -> None: """Re-installs all packages. Uses an asynchronous thread when possible.""" - self.update_worker = AsynchronousUpdateWorker() - updates = [item.name for item in self.package_list] - self.update_worker.package_list = updates + if updates: + self._install_or_update_packages(updates, PipCommand.Upgrade) + + def install_packages(self, packages: list[str]) -> None: + """Installs packages. Uses an asynchronous thread when possible.""" + installed = (item.name for item in self.package_list) + self._install_or_update_packages([*installed, *packages], PipCommand.Install) + + def _install_or_update_packages(self, packages: list[str], command: PipCommand) -> None: + """Installs/Upgrade packages. Uses an asynchronous thread when possible.""" + self.update_worker = AsynchronousPipWorker(command, packages) if not using_system_pip_installation_location(): # pip doesn't properly update when using the target directory, so we have to delete # it and reinstall @@ -356,9 +361,16 @@ def update_all_packages(self): def update_call_finished(self): self.update_complete.emit() if not using_system_pip_installation_location(): - shutil.rmtree(self.vendor_path + ".old") - # Clean up old package versions that may remain after update - self._cleanup_old_package_versions() + if self.update_worker.error: + try: + os.rename(self.vendor_path + ".old", self.vendor_path) + except Exception as err: + fci.Console.PrintError(f"Backup restore failed: {self.vendor_path}.old.\n") + fci.Console.PrintError(f"{err}\n") + else: + shutil.rmtree(self.vendor_path + ".old") + # Clean up old package versions that may remain after update + self._cleanup_old_package_versions() def _cleanup_old_package_versions(self): """Remove old package version metadata directories after an update. diff --git a/addonmanager_python_deps_commands.py b/addonmanager_python_deps_commands.py new file mode 100644 index 00000000..4da9202b --- /dev/null +++ b/addonmanager_python_deps_commands.py @@ -0,0 +1,75 @@ +# SPDX-License-Identifier: LGPL-2.1-or-later +# SPDX-FileCopyrightText: 2022 FreeCAD Project Association +# SPDX-FileNotice: Part of the AddonManager. + +################################################################################ +# # +# This addon 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 addon 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 addon. If not, see https://www.gnu.org/licenses # +# # +################################################################################ + +""" +Provides addition atop level command to launch the pypi package installer. +""" + +from pathlib import Path + + +def QT_TRANSLATE_NOOP(_, txt) -> str: + return txt + + +class Std_AddonMgrPip: + """Launch the Pip Installer Dialog.""" + + def GetResources(self) -> dict[str, str]: + return { + "Pixmap": str(Path(__file__).parent / "Resources" / "icons" / "add_pypi_package.svg"), + "MenuText": QT_TRANSLATE_NOOP( + "AddonsInstaller", + "Python package manager", + ), + "ToolTip": QT_TRANSLATE_NOOP( + "AddonsInstaller", + "Open python packages manager", + ), + } + + def Activated(self) -> None: + from addonmanager_python_deps_gui import PythonPackageManagerGui + from package_list import PackageListItemModel + model = PackageListItemModel() + dialog = PythonPackageManagerGui(model.repos) + dialog.show() + + def IsActive(self) -> bool: + return True + + def modifyMenuBar(self) -> list[dict[str, str]]: + return [ + { + "insert": "Std_AddonMgrPip", + "menuItem": "Std_AddonMgr", + "after": "", + } + ] + + @classmethod + def install(cls) -> None: + import FreeCADGui as Gui + + Gui.addCommand("Std_AddonMgrPip", Std_AddonMgrPip()) + cls._instance = cls() + Gui.addWorkbenchManipulator(cls._instance) + diff --git a/addonmanager_python_deps_gui.py b/addonmanager_python_deps_gui.py index 36d5bb5b..596cce1c 100644 --- a/addonmanager_python_deps_gui.py +++ b/addonmanager_python_deps_gui.py @@ -21,8 +21,7 @@ """GUI for python dependency management.""" -import os - +from pathlib import Path import addonmanager_freecad_interface as fci from addonmanager_python_deps import PythonPackageListModel @@ -30,53 +29,62 @@ translate = fci.translate +base_path = Path(__file__).parent class PythonPackageManagerGui: """GUI for managing Python packages""" + _ui = base_path / "PythonDependencyUpdateDialog.ui" + _icons = base_path / "Resources" / "icons" + def __init__(self, addons): - self.dlg = fci.loadUi( - os.path.join(os.path.dirname(__file__), "PythonDependencyUpdateDialog.ui") - ) + self.dlg = fci.loadUi(str(self._ui)) self.dlg.setObjectName("AddonManager_PythonDependencyUpdateDialog") self.model = PythonPackageListModel(addons) self.dlg.tableView.setModel(self.model) + self.dlg.setMinimumHeight(400) - self.dlg.tableView.horizontalHeader().setStretchLastSection(True) - self.dlg.tableView.horizontalHeader().setSectionResizeMode( - 0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents - ) - self.dlg.tableView.horizontalHeader().setSectionResizeMode( - 1, QtWidgets.QHeaderView.ResizeMode.ResizeToContents - ) - self.dlg.tableView.horizontalHeader().setSectionResizeMode( - 2, QtWidgets.QHeaderView.ResizeMode.ResizeToContents - ) - self.dlg.tableView.horizontalHeader().setSectionResizeMode( - 3, QtWidgets.QHeaderView.ResizeMode.ResizeToContents - ) + header = self.dlg.tableView.horizontalHeader() + header.setStretchLastSection(True) + resizeMode = QtWidgets.QHeaderView.ResizeMode.ResizeToContents + for col in range(4): + header.setSectionResizeMode(col, resizeMode) + self.dlg.buttonInstallPkgs.clicked.connect(self._install_button_clicked) self.dlg.buttonUpdateAll.clicked.connect(self._update_button_clicked) self.model.modelReset.connect(self._model_was_reset) self.model.update_complete.connect(self._update_complete) def show(self): - self.dlg.buttonUpdateAll.setEnabled(False) - self.dlg.updateInProgressLabel.show() + self._working(True) self.model.reset_package_list() self.dlg.labelInstallationPath.setText(self.model.vendor_path) self.dlg.exec() + def _working(self, working: bool) -> None: + self.dlg.buttonInstallPkgs.setEnabled(not working) + self.dlg.buttonUpdateAll.setEnabled(not working and self.model.updates_are_available()) + if working: + self.dlg.updateInProgressLabel.show() + else: + self.dlg.updateInProgressLabel.hide() + + def _install_button_clicked(self): + title = translate("AddonsInstaller", "Install") + prompt = translate("AddonsInstaller", "Packages:") + packages, ok = QtWidgets.QInputDialog.getText(self.dlg, title, prompt) + if packages and ok: + self._working(True) + self.model.install_packages(packages.split()) + def _update_button_clicked(self): - self.dlg.buttonUpdateAll.setEnabled(False) - self.dlg.updateInProgressLabel.show() + self._working(True) self.model.update_all_packages() def _model_was_reset(self): - self.dlg.updateInProgressLabel.hide() - self.dlg.buttonUpdateAll.setEnabled(self.model.updates_are_available()) + self._working(False) def _update_complete(self): - self.dlg.updateInProgressLabel.hide() + self._working(True) self.model.reset_package_list() From 324ae2c3b379de07805ae2b640e895f01d8b2b14 Mon Sep 17 00:00:00 2001 From: Frank Martinez Date: Tue, 19 May 2026 08:26:52 -0500 Subject: [PATCH 2/5] Fix column label (cherry picked from commit b859c3e8a214557731808039c0d9904292776bfd) --- addonmanager_python_deps.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addonmanager_python_deps.py b/addonmanager_python_deps.py index e97953e9..0a24f3f1 100644 --- a/addonmanager_python_deps.py +++ b/addonmanager_python_deps.py @@ -301,7 +301,7 @@ def headerData(self, section, orientation, role=...) -> Optional[str]: elif section == 2: return translate("AddonsInstaller", "Available Version") elif section == 3: - return translate("AddonsInstaller", "Dependent Addons") + return translate("AddonsInstaller", "Used By") return None def flags(self, index) -> QtCore.Qt.ItemFlag: From e701dba29b6d206c6e3fca4b49a750d0d294c22f Mon Sep 17 00:00:00 2001 From: Frank Martinez Date: Tue, 19 May 2026 11:57:04 -0500 Subject: [PATCH 3/5] remove pypi icon (cherry picked from commit aadf1a55c19948730a554d45d683be045774399e) --- Resources/icons/add_pypi_package.svg | 220 --------------------------- addonmanager_python_deps.py | 1 + addonmanager_python_deps_commands.py | 3 +- 3 files changed, 3 insertions(+), 221 deletions(-) delete mode 100644 Resources/icons/add_pypi_package.svg diff --git a/Resources/icons/add_pypi_package.svg b/Resources/icons/add_pypi_package.svg deleted file mode 100644 index cdef240d..00000000 --- a/Resources/icons/add_pypi_package.svg +++ /dev/null @@ -1,220 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/addonmanager_python_deps.py b/addonmanager_python_deps.py index 0a24f3f1..3b877ebb 100644 --- a/addonmanager_python_deps.py +++ b/addonmanager_python_deps.py @@ -151,6 +151,7 @@ class PipCommand(Enum): Upgrade = 1 List = 2 + class AsynchronousPipWorker(QtCore.QObject): """A worker class that runs pip to install/update/list packages.""" diff --git a/addonmanager_python_deps_commands.py b/addonmanager_python_deps_commands.py index 4da9202b..72d59c14 100644 --- a/addonmanager_python_deps_commands.py +++ b/addonmanager_python_deps_commands.py @@ -35,7 +35,7 @@ class Std_AddonMgrPip: def GetResources(self) -> dict[str, str]: return { - "Pixmap": str(Path(__file__).parent / "Resources" / "icons" / "add_pypi_package.svg"), + "Pixmap": "applications-python.svg", "MenuText": QT_TRANSLATE_NOOP( "AddonsInstaller", "Python package manager", @@ -49,6 +49,7 @@ def GetResources(self) -> dict[str, str]: def Activated(self) -> None: from addonmanager_python_deps_gui import PythonPackageManagerGui from package_list import PackageListItemModel + model = PackageListItemModel() dialog = PythonPackageManagerGui(model.repos) dialog.show() From 9797a0db5433b32b6a243de210aaa93d588ea0bb Mon Sep 17 00:00:00 2001 From: Frank Martinez Date: Tue, 19 May 2026 12:19:06 -0500 Subject: [PATCH 4/5] Remove unused imports (cherry picked from commit 6aeafcebe35bc398d983924eebab8d5e78c69a51) --- addonmanager_python_deps_commands.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/addonmanager_python_deps_commands.py b/addonmanager_python_deps_commands.py index 72d59c14..f47ce7bb 100644 --- a/addonmanager_python_deps_commands.py +++ b/addonmanager_python_deps_commands.py @@ -23,9 +23,6 @@ Provides addition atop level command to launch the pypi package installer. """ -from pathlib import Path - - def QT_TRANSLATE_NOOP(_, txt) -> str: return txt From a000a5ec8722dce9b8fa7382f9ba07039aa8bab7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 18:33:06 +0000 Subject: [PATCH 5/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci (cherry picked from commit 2ee9516fc20d762a8a0d77cbc0cb4c224f0c74d7) --- addonmanager_python_deps_commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/addonmanager_python_deps_commands.py b/addonmanager_python_deps_commands.py index f47ce7bb..8d6dd6e6 100644 --- a/addonmanager_python_deps_commands.py +++ b/addonmanager_python_deps_commands.py @@ -23,6 +23,7 @@ Provides addition atop level command to launch the pypi package installer. """ + def QT_TRANSLATE_NOOP(_, txt) -> str: return txt @@ -70,4 +71,3 @@ def install(cls) -> None: Gui.addCommand("Std_AddonMgrPip", Std_AddonMgrPip()) cls._instance = cls() Gui.addWorkbenchManipulator(cls._instance) -