Addon Manager: Add dialog to manage Python deps
This commit is contained in:
@@ -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"""
|
||||
|
||||
|
||||
@@ -121,6 +121,19 @@
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="buttonUpdateDependencies">
|
||||
<property name="enabled">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>View and update Python package dependencies</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Update dependencies</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<spacer name="horizontalSpacer_4">
|
||||
<property name="orientation">
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
91
src/Mod/AddonManager/PythonDependencyUpdateDialog.ui
Normal file
91
src/Mod/AddonManager/PythonDependencyUpdateDialog.ui
Normal file
@@ -0,0 +1,91 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>PythonDependencyUpdateDialog</class>
|
||||
<widget class="QDialog" name="PythonDependencyUpdateDialog">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>528</width>
|
||||
<height>300</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Manage Python Dependencies</string>
|
||||
</property>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="text">
|
||||
<string>The following Python packages have been installed locally by the Addon Manager to satisfy Addon dependencies. Installation location:</string>
|
||||
</property>
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="labelInstallationPath">
|
||||
<property name="text">
|
||||
<string notr="true">placeholder for path</string>
|
||||
</property>
|
||||
<property name="textInteractionFlags">
|
||||
<set>Qt::TextSelectableByMouse</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTableWidget" name="tableWidget">
|
||||
<property name="alternatingRowColors">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="selectionBehavior">
|
||||
<enum>QAbstractItemView::SelectRows</enum>
|
||||
</property>
|
||||
<property name="sortingEnabled">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
<property name="columnCount">
|
||||
<number>4</number>
|
||||
</property>
|
||||
<attribute name="verticalHeaderVisible">
|
||||
<bool>false</bool>
|
||||
</attribute>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Package name</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Installed version</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Available version</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QPushButton" name="buttonUpdateAll">
|
||||
<property name="text">
|
||||
<string>Update all available</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
202
src/Mod/AddonManager/manage_python_dependencies.py
Normal file
202
src/Mod/AddonManager/manage_python_dependencies.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user