diff --git a/InitGui.py b/InitGui.py index 5e46866..b251b5e 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 66d4a33..46bab39 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/addonmanager_python_deps.py b/addonmanager_python_deps.py index a2bda8c..3b877eb 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,69 @@ 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() + if self.command in (PipCommand.Upgrade, PipCommand.Install): + self._install_or_update() + self._list() -class AsynchronousUpdateWorker(QtCore.QObject): - """A worker class that runs pip to generate the package 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 +241,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 +302,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", "Used By") return None def flags(self, index) -> QtCore.Qt.ItemFlag: @@ -331,12 +329,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 +362,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 0000000..8d6dd6e --- /dev/null +++ b/addonmanager_python_deps_commands.py @@ -0,0 +1,73 @@ +# 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. +""" + + +def QT_TRANSLATE_NOOP(_, txt) -> str: + return txt + + +class Std_AddonMgrPip: + """Launch the Pip Installer Dialog.""" + + def GetResources(self) -> dict[str, str]: + return { + "Pixmap": "applications-python.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 36d5bb5..596cce1 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()