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