Addon Manager: Add dialog to manage Python deps

This commit is contained in:
Chris Hennes
2022-07-27 08:43:59 -05:00
parent 66ff55a2aa
commit e415f38fe6
7 changed files with 390 additions and 43 deletions

View File

@@ -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"""

View File

@@ -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">

View File

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

View 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>

View File

@@ -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

View File

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

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