diff --git a/src/Mod/AddonManager/AddonManager.py b/src/Mod/AddonManager/AddonManager.py index 7732042f72..7afed4d8eb 100644 --- a/src/Mod/AddonManager/AddonManager.py +++ b/src/Mod/AddonManager/AddonManager.py @@ -45,6 +45,11 @@ from install_to_toolbar import ( ask_to_install_toolbar_button, remove_custom_toolbar_button, ) +from manage_python_dependencies import ( + check_for_python_package_updates, + CheckForPythonPackageUpdatesWorker, + PythonPackageManager +) from NetworkManager import HAVE_QTNETWORK, InitializeNetworkManager @@ -95,6 +100,7 @@ class CommandAddonManager: "load_macro_metadata_worker", "update_all_worker", "dependency_installation_worker", + "check_for_python_package_updates_worker" ] lock = threading.Lock() @@ -325,6 +331,9 @@ class CommandAddonManager: translate("AddonsInstaller", "Starting up...") ) + # Only shown if there are available Python package updates + self.dialog.buttonUpdateDependencies.hide() + # connect slots self.dialog.rejected.connect(self.reject) self.dialog.buttonUpdateAll.clicked.connect(self.update_all) @@ -334,6 +343,7 @@ class CommandAddonManager: self.dialog.buttonCheckForUpdates.clicked.connect( lambda: self.force_check_updates(standalone=True) ) + self.dialog.buttonUpdateDependencies.clicked.connect(self.show_python_updates_dialog) self.packageList.itemSelected.connect(self.table_row_activated) self.packageList.setEnabled(False) self.packageDetails.execute.connect(self.executemacro) @@ -567,6 +577,7 @@ class CommandAddonManager: self.populate_macros, self.update_metadata_cache, self.check_updates, + self.check_python_updates ] pref = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") if pref.GetBool("DownloadMacros", False): @@ -850,6 +861,27 @@ class CommandAddonManager: self.enable_updates(len(self.packages_with_updates)) self.dialog.buttonCheckForUpdates.setEnabled(True) + def check_python_updates(self) -> None: + if hasattr(self, "check_for_python_package_updates_worker"): + thread = self.check_for_python_package_updates_worker + if thread: + if not thread.isFinished(): + self.do_next_startup_phase() + return + self.check_for_python_package_updates_worker = CheckForPythonPackageUpdatesWorker() + self.check_for_python_package_updates_worker.python_package_updates_available.connect( + lambda: self.dialog.buttonUpdateDependencies.show() + ) + self.check_for_python_package_updates_worker.finished.connect( + self.do_next_startup_phase + ) + self.check_for_python_package_updates_worker.start() + + def show_python_updates_dialog(self) -> None: + if not hasattr(self,"manage_python_packages_dialog"): + self.manage_python_packages_dialog = PythonPackageManager() + self.manage_python_packages_dialog.show() + def add_addon_repo(self, addon_repo: Addon) -> None: """adds a workbench to the list""" diff --git a/src/Mod/AddonManager/AddonManager.ui b/src/Mod/AddonManager/AddonManager.ui index c6f6c66fa0..463c82e7e4 100644 --- a/src/Mod/AddonManager/AddonManager.ui +++ b/src/Mod/AddonManager/AddonManager.ui @@ -121,6 +121,19 @@ + + + + true + + + View and update Python package dependencies + + + Update dependencies + + + diff --git a/src/Mod/AddonManager/CMakeLists.txt b/src/Mod/AddonManager/CMakeLists.txt index bfc7772c75..5a536fa67c 100644 --- a/src/Mod/AddonManager/CMakeLists.txt +++ b/src/Mod/AddonManager/CMakeLists.txt @@ -22,9 +22,11 @@ SET(AddonManager_SRCS InitGui.py install_to_toolbar.py loading.html + manage_python_dependencies.py NetworkManager.py package_details.py package_list.py + PythonDependencyUpdateDialog.ui select_toolbar_dialog.ui TestAddonManagerApp.py ) diff --git a/src/Mod/AddonManager/PythonDependencyUpdateDialog.ui b/src/Mod/AddonManager/PythonDependencyUpdateDialog.ui new file mode 100644 index 0000000000..0bb719bf19 --- /dev/null +++ b/src/Mod/AddonManager/PythonDependencyUpdateDialog.ui @@ -0,0 +1,91 @@ + + + PythonDependencyUpdateDialog + + + + 0 + 0 + 528 + 300 + + + + Manage Python Dependencies + + + + + + The following Python packages have been installed locally by the Addon Manager to satisfy Addon dependencies. Installation location: + + + true + + + + + + + placeholder for path + + + Qt::TextSelectableByMouse + + + + + + + true + + + QAbstractItemView::SelectRows + + + true + + + 4 + + + false + + + + Package name + + + + + Installed version + + + + + Available version + + + + + + + + + + + + + + + Update all available + + + + + + + + + + diff --git a/src/Mod/AddonManager/addonmanager_utilities.py b/src/Mod/AddonManager/addonmanager_utilities.py index 52e255c6d6..e0e89362e6 100644 --- a/src/Mod/AddonManager/addonmanager_utilities.py +++ b/src/Mod/AddonManager/addonmanager_utilities.py @@ -346,3 +346,40 @@ def is_float(element: Any) -> bool: # @} + +def get_python_exe() -> str: + # Find Python. In preference order + # A) The value of the PythonExecutableForPip user preference + # B) The executable located in the same bin directory as FreeCAD and called "python3" + # C) The executable located in the same bin directory as FreeCAD and called "python" + # D) The result of an shutil search for your system's "python3" executable + # E) The result of an shutil search for your system's "python" executable + prefs = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") + python_exe = prefs.GetString("PythonExecutableForPip", "Not set") + if ( + not python_exe + or python_exe == "Not set" + or not os.path.exists(python_exe) + ): + fc_dir = FreeCAD.getHomePath() + python_exe = os.path.join(fc_dir, "bin", "python3") + if "Windows" in platform.system(): + python_exe += ".exe" + + if not python_exe or not os.path.exists(python_exe): + python_exe = os.path.join(fc_dir, "bin", "python") + if "Windows" in platform.system(): + python_exe += ".exe" + + if not python_exe or not os.path.exists(python_exe): + python_exe = shutil.which("python3") + + if not python_exe or not os.path.exists(python_exe): + python_exe = shutil.which("python") + + if not python_exe or not os.path.exists(python_exe): + self.no_python_exe.emit() + return "" + + prefs.SetString("PythonExecutableForPip", python_exe) + return python_exe \ No newline at end of file diff --git a/src/Mod/AddonManager/addonmanager_workers.py b/src/Mod/AddonManager/addonmanager_workers.py index 9b8c14bba1..5f5d949e62 100644 --- a/src/Mod/AddonManager/addonmanager_workers.py +++ b/src/Mod/AddonManager/addonmanager_workers.py @@ -1300,50 +1300,18 @@ class DependencyInstallationWorker(QtCore.QThread): QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 50) if self.python_required or self.python_optional: - - # Find Python. In preference order - # A) The value of the PythonExecutableForPip user preference - # B) The executable located in the same bin directory as FreeCAD and called "python3" - # C) The executable located in the same bin directory as FreeCAD and called "python" - # D) The result of an shutil search for your system's "python3" executable - # E) The result of an shutil search for your system's "python" executable - prefs = FreeCAD.ParamGet("User parameter:BaseApp/Preferences/Addons") - python_exe = prefs.GetString("PythonExecutableForPip", "Not set") - if ( - not python_exe - or python_exe == "Not set" - or not os.path.exists(python_exe) - ): - fc_dir = FreeCAD.getHomePath() - python_exe = os.path.join(fc_dir, "bin", "python3") - if "Windows" in platform.system(): - python_exe += ".exe" - - if not python_exe or not os.path.exists(python_exe): - python_exe = os.path.join(fc_dir, "bin", "python") - if "Windows" in platform.system(): - python_exe += ".exe" - - if not python_exe or not os.path.exists(python_exe): - python_exe = shutil.which("python3") - - if not python_exe or not os.path.exists(python_exe): - python_exe = shutil.which("python") - - if not python_exe or not os.path.exists(python_exe): - self.no_python_exe.emit() - return - - prefs.SetString("PythonExecutableForPip", python_exe) - + python_exe = utils.get_python_exe() pip_failed = False - try: - proc = subprocess.run( - [python_exe, "-m", "pip", "--version"], stdout=subprocess.PIPE - ) - except subprocess.CalledProcessError as e: - pip_failed = True - if proc.returncode != 0: + if python_exe: + try: + proc = subprocess.run( + [python_exe, "-m", "pip", "--version"], stdout=subprocess.PIPE + ) + except subprocess.CalledProcessError as e: + pip_failed = True + if proc.returncode != 0: + pip_failed = True + else: pip_failed = True if pip_failed: self.no_pip.emit(f"{python_exe} -m pip --version") @@ -1375,6 +1343,8 @@ class DependencyInstallationWorker(QtCore.QThread): stdout=subprocess.PIPE, stderr=subprocess.PIPE, ) + # Note to self: how to list installed packages + # ./python.exe -m pip list --path ~/AppData/Roaming/FreeCAD/AdditionalPythonPackages FreeCAD.Console.PrintMessage(proc.stdout.decode()) if proc.returncode != 0: self.failure.emit( diff --git a/src/Mod/AddonManager/manage_python_dependencies.py b/src/Mod/AddonManager/manage_python_dependencies.py new file mode 100644 index 0000000000..b715217936 --- /dev/null +++ b/src/Mod/AddonManager/manage_python_dependencies.py @@ -0,0 +1,202 @@ +# *************************************************************************** +# * * +# * Copyright (c) 2022 FreeCAD Project Association * +# * * +# * This program 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 program 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 library; if not, write to the Free Software * +# * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA * +# * 02110-1301 USA * +# * * +# *************************************************************************** + +import FreeCAD +import FreeCADGui +from PySide2 import QtCore, QtGui, QtWidgets +import addonmanager_utilities as utils +from typing import List, Dict + +import os +import subprocess +from functools import partial, partialmethod + +translate = FreeCAD.Qt.translate + +# For non-blocking update availability checking: +class CheckForPythonPackageUpdatesWorker(QtCore.QThread): + + python_package_updates_available = QtCore.Signal() + + def __init__(self): + QtCore.QThread.__init__(self) + + def run(self): + current_thread = QtCore.QThread.currentThread() + if check_for_python_package_updates(): + self.python_package_updates_available.emit() + +def check_for_python_package_updates() -> bool: + vendor_path = os.path.join( + FreeCAD.getUserAppDataDir(), "AdditionalPythonPackages" + ) + package_counter = 0 + outdated_packages_stdout = call_pip(["list","-o","--path",vendor_path]) + FreeCAD.Console.PrintLog("Output from pip -o:\n") + for line in outdated_packages_stdout: + if len(line) > 0: + package_counter += 1 + FreeCAD.Console.PrintLog(f" {line}\n") + return package_counter > 0 + +def call_pip(args) -> List[str]: + python_exe = utils.get_python_exe() + pip_failed = False + if python_exe: + try: + call_args = [python_exe, "-m", "pip", "--disable-pip-version-check"] + call_args.extend(args) + print(call_args) + proc = subprocess.run( + call_args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True + ) + except subprocess.CalledProcessError: + pip_failed = True + if proc.returncode != 0: + pip_failed = True + else: + pip_failed = True + + result = [] + if not pip_failed: + data = proc.stdout.decode() + result = data.split("\n") + else: + print(proc.stderr.decode()) + raise Exception(proc.stderr.decode()) + return result + + +class PythonPackageManager: + + def __init__(self): + self.dlg = FreeCADGui.PySideUic.loadUi( + os.path.join(os.path.dirname(__file__), "PythonDependencyUpdateDialog.ui") + ) + self.vendor_path = os.path.join( + FreeCAD.getUserAppDataDir(), "AdditionalPythonPackages" + ) + + def show(self): + self._create_list_from_pip() + self.dlg.setWindowFlag(QtCore.Qt.WindowStaysOnTopHint, True) + self.dlg.tableWidget.setSortingEnabled(False) + self.dlg.labelInstallationPath.setText(self.vendor_path) + self.dlg.exec() + + def _create_list_from_pip(self): + all_packages_stdout = call_pip(["list","--path",self.vendor_path]) + outdated_packages_stdout = call_pip(["list","-o","--path",self.vendor_path]) + package_list = self._parse_pip_list_output(all_packages_stdout, outdated_packages_stdout) + self.dlg.buttonUpdateAll.clicked.connect(partial(self._update_all_packages, package_list)) + + self.dlg.tableWidget.setRowCount(len(package_list)) + updateButtons = list() + counter = 0 + update_counter = 0 + self.dlg.tableWidget.setSortingEnabled(False) + for package_name, package_details in package_list.items(): + self.dlg.tableWidget.setItem(counter,0,QtWidgets.QTableWidgetItem(package_name)) + self.dlg.tableWidget.setItem(counter,1,QtWidgets.QTableWidgetItem(package_details["installed_version"])) + self.dlg.tableWidget.setItem(counter,2,QtWidgets.QTableWidgetItem(package_details["available_version"])) + if len(package_details["available_version"]) > 0: + updateButtons.append(QtWidgets.QPushButton(translate("AddonsInstaller","Update"))) + updateButtons[-1].setIcon(QtGui.QIcon(":/icons/button_up.svg")) + updateButtons[-1].clicked.connect(partial(self._update_package,package_name)) + self.dlg.tableWidget.setCellWidget(counter,3,updateButtons[-1]) + update_counter += 1 + else: + self.dlg.tableWidget.removeCellWidget(counter,3) + counter += 1 + self.dlg.tableWidget.setSortingEnabled(True) + + self.dlg.tableWidget.horizontalHeader().setStretchLastSection(False) + self.dlg.tableWidget.horizontalHeader().setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch) + self.dlg.tableWidget.horizontalHeader().setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents) + self.dlg.tableWidget.horizontalHeader().setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeToContents) + self.dlg.tableWidget.horizontalHeader().setSectionResizeMode(3, QtWidgets.QHeaderView.ResizeToContents) + + if update_counter > 0: + self.dlg.buttonUpdateAll.setEnabled(True) + else: + self.dlg.buttonUpdateAll.setEnabled(False) + + def _parse_pip_list_output(self, all_packages, outdated_packages) -> Dict[str,Dict[str,str]]: + # All Packages output looks like this: + # Package Version + # ---------- ------- + # gitdb 4.0.9 + # GitPython 3.1.27 + # setuptools 41.2.0 + + # Outdated Packages output looks like this: + # Package Version Latest Type + # ---------- ------- ------ ----- + # pip 21.0.1 22.1.2 wheel + # setuptools 41.2.0 63.2.0 wheel + + packages = {} + skip_counter = 0 + for line in all_packages: + if skip_counter < 2: + skip_counter += 1 + continue + entries = line.split() + if entries: + print(entries[0]) + if len(entries) > 1: + package_name = entries[0] + installed_version = entries[1] + packages[package_name] = {"installed_version":installed_version,"available_version":""} + else: + print(line) + + skip_counter = 0 + for line in outdated_packages: + if skip_counter < 2: + skip_counter += 1 + continue + entries = line.split() + if len(entries) > 1: + package_name = entries[0] + installed_version = entries[1] + available_version = entries[2] + packages[package_name] = {"installed_version":installed_version,"available_version":available_version} + else: + print(line) + + return packages + + def _update_package(self, package_name) -> None: + for line in range(self.dlg.tableWidget.rowCount()): + if self.dlg.tableWidget.item(line,0).text() == package_name: + self.dlg.tableWidget.setItem(line,2,QtWidgets.QTableWidgetItem(translate("AddonsInstaller","Updating..."))) + break + QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 50) + + call_pip(["install","--upgrade",package_name,"--target",self.vendor_path]) + self._create_list_from_pip() + QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 50) + + def _update_all_packages(self, package_list) -> None: + for package_name, package_details in package_list.items(): + if len(package_details["available_version"]) > 0 and package_details["available_version"] != package_details["installed_version"]: + self._update_package(package_name) \ No newline at end of file