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