Addon Manager: Improve failed pip behavior (#7552)

* Addon Manager: Improve failed pip behavior
* Addon Manager: pylint cleanup
* Addon Manager: Use subprocess.CREATE_NO_WINDOW when possible
* Addon Manager: Put pip calls in QThread
* Addon Manager: Remove Py package check from startup
This commit is contained in:
Chris Hennes
2022-10-04 06:58:19 -07:00
committed by GitHub
parent a85f751668
commit 0963860dd6
2 changed files with 97 additions and 46 deletions

View File

@@ -65,7 +65,6 @@ from install_to_toolbar import (
remove_custom_toolbar_button,
)
from manage_python_dependencies import (
CheckForPythonPackageUpdatesWorker,
PythonPackageManager,
)
from addonmanager_devmode import DeveloperMode
@@ -915,23 +914,8 @@ class CommandAddonManager:
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.update_allowed_packages_list() # Not really the best place for it...
self.check_for_python_package_updates_worker.start()
self.do_next_startup_phase()
def show_python_updates_dialog(self) -> None:
if not hasattr(self, "manage_python_packages_dialog"):

View File

@@ -37,6 +37,11 @@ import addonmanager_utilities as utils
translate = FreeCAD.Qt.translate
#pylint: disable=too-few-public-methods
class PipFailed(Exception):
"""Exception thrown when pip times out or otherwise fails to return valid results"""
class CheckForPythonPackageUpdatesWorker(QtCore.QThread):
"""Perform non-blocking Python library update availability checking"""
@@ -47,21 +52,25 @@ class CheckForPythonPackageUpdatesWorker(QtCore.QThread):
QtCore.QThread.__init__(self)
def run(self):
"""Usually not called directly: instead, instantiate this class and call its start() function
in a parent thread. emits a python_package_updates_available signal if updates are available
for any of the installed Python packages."""
"""Usually not called directly: instead, instantiate this class and call its start()
function in a parent thread. emits a python_package_updates_available signal if updates
are available for any of the installed Python packages."""
if check_for_python_package_updates():
self.python_package_updates_available.emit()
def check_for_python_package_updates() -> bool:
"""Returns True if any of the Python packages installed into the AdditionalPythonPackages directory
have updates available, or False if the are all up-to-date."""
"""Returns True if any of the Python packages installed into the AdditionalPythonPackages
directory have updates available, or False if the are all up-to-date."""
vendor_path = os.path.join(FreeCAD.getUserAppDataDir(), "AdditionalPythonPackages")
package_counter = 0
outdated_packages_stdout = call_pip(["list", "-o", "--path", vendor_path])
try:
outdated_packages_stdout = call_pip(["list", "-o", "--path", vendor_path])
except PipFailed as e:
FreeCAD.Console.PrintError(str(e) + "\n")
return False
FreeCAD.Console.PrintLog("Output from pip -o:\n")
for line in outdated_packages_stdout:
if len(line) > 0:
@@ -71,8 +80,8 @@ def check_for_python_package_updates() -> bool:
def call_pip(args) -> List[str]:
"""Tries to locate the appropriate Python executable and run pip with version checking disabled. Fails
if Python can't be found or if pip is not installed."""
"""Tries to locate the appropriate Python executable and run pip with version checking
disabled. Fails if Python can't be found or if pip is not installed."""
python_exe = utils.get_python_exe()
pip_failed = False
@@ -81,13 +90,16 @@ def call_pip(args) -> List[str]:
call_args.extend(args)
proc = None
try:
no_window_flag = 0
if hasattr(subprocess,"CREATE_NO_WINDOW"):
no_window_flag = subprocess.CREATE_NO_WINDOW # Added in Python 3.7
proc = subprocess.run(
call_args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
shell=True,
check=True,
timeout=30,
creationflags=no_window_flag,
)
if proc.returncode != 0:
pip_failed = True
@@ -99,6 +111,7 @@ def call_pip(args) -> List[str]:
"AddonsInstaller",
"pip took longer than 30 seconds to return results, giving up on it",
)
+ "\n"
)
FreeCAD.Console.PrintLog(" ".join(call_args))
pip_failed = True
@@ -108,26 +121,54 @@ def call_pip(args) -> List[str]:
data = proc.stdout.decode()
result = data.split("\n")
elif proc:
raise Exception(proc.stderr.decode())
raise PipFailed(proc.stderr.decode())
else:
raise Exception("pip timed out")
raise PipFailed("pip timed out")
else:
raise Exception("Could not locate Python executable on this system")
raise PipFailed("Could not locate Python executable on this system")
return result
class PythonPackageManager:
"""A GUI-based pip interface allowing packages to be updated, either individually or all at once."""
"""A GUI-based pip interface allowing packages to be updated, either individually or all at
once."""
class PipRunner(QtCore.QObject):
""" Run pip in a separate thread so the UI doesn't block while it runs """
finished = QtCore.Signal()
error = QtCore.Signal(str)
def __init__(self, vendor_path, parent=None):
super().__init__(parent)
self.all_packages_stdout = []
self.outdated_packages_stdout = []
self.vendor_path = vendor_path
def process(self):
""" Execute this object. """
try:
self.all_packages_stdout = call_pip(["list", "--path", self.vendor_path])
self.outdated_packages_stdout = call_pip(
["list", "-o", "--path", self.vendor_path]
)
except PipFailed as e:
FreeCAD.Console.PrintError(str(e) + "\n")
self.error.emit(str(e))
self.finished.emit()
def __init__(self, addons):
self.dlg = FreeCADGui.PySideUic.loadUi(
os.path.join(os.path.dirname(__file__), "PythonDependencyUpdateDialog.ui")
)
self.addons = addons
self.vendor_path = os.path.join(
FreeCAD.getUserAppDataDir(), "AdditionalPythonPackages"
)
self.addons = addons
self.worker_thread = None
self.worker_object = None
def show(self):
"""Run the modal dialog"""
@@ -140,10 +181,30 @@ class PythonPackageManager:
def _create_list_from_pip(self):
"""Uses pip and pip -o to generate a list of installed packages, and creates the user
interface elements for those packages."""
interface elements for those packages. Asynchronous, will complete AFTER the window is
showing in most cases. """
self.worker_thread = QtCore.QThread()
self.worker_object = PythonPackageManager.PipRunner(self.vendor_path)
self.worker_object.moveToThread(self.worker_thread)
self.worker_object.finished.connect(self._worker_finished)
self.worker_object.finished.connect(self.worker_thread.quit)
self.worker_thread.started.connect(self.worker_object.process)
self.worker_thread.start()
self.dlg.tableWidget.setRowCount(1)
self.dlg.tableWidget.setItem(
0, 0, QtWidgets.QTableWidgetItem(translate("AddonsInstaller","Processing, please wait..."))
)
self.dlg.tableWidget.horizontalHeader().setSectionResizeMode(
0, QtWidgets.QHeaderView.ResizeToContents
)
def _worker_finished(self):
""" Callback for when the worker process has completed """
all_packages_stdout = self.worker_object.all_packages_stdout
outdated_packages_stdout = self.worker_object.outdated_packages_stdout
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
)
@@ -161,9 +222,9 @@ class PythonPackageManager:
dependencies = []
for addon in dependent_addons:
if addon["optional"]:
dependencies.append(addon['name'] + "*")
dependencies.append(addon["name"] + "*")
else:
dependencies.append(addon['name'])
dependencies.append(addon["name"])
self.dlg.tableWidget.setItem(
counter, 0, QtWidgets.QTableWidgetItem(package_name)
)
@@ -190,7 +251,7 @@ class PythonPackageManager:
updateButtons[-1].clicked.connect(
partial(self._update_package, package_name)
)
self.dlg.tableWidget.setCellWidget(counter, 3, updateButtons[-1])
self.dlg.tableWidget.setCellWidget(counter, 4, updateButtons[-1])
update_counter += 1
else:
self.dlg.tableWidget.removeCellWidget(counter, 3)
@@ -219,18 +280,18 @@ class PythonPackageManager:
def _get_dependent_addons(self, package):
dependent_addons = []
for addon in self.addons:
#if addon.installed_version is not None:
if package.lower() in addon.python_requires:
dependent_addons.append({"name":addon.name,"optional":False})
elif package.lower() in addon.python_optional:
dependent_addons.append({"name":addon.name,"optional":True})
# if addon.installed_version is not None:
if package.lower() in addon.python_requires:
dependent_addons.append({"name": addon.name, "optional": False})
elif package.lower() in addon.python_optional:
dependent_addons.append({"name": addon.name, "optional": True})
return dependent_addons
def _parse_pip_list_output(
self, all_packages, outdated_packages
) -> Dict[str, Dict[str, str]]:
"""Parses the output from pip into a dictionary with update information in it. The pip output should
be an array of lines of text."""
"""Parses the output from pip into a dictionary with update information in it. The pip
output should be an array of lines of text."""
# All Packages output looks like this:
# Package Version
@@ -291,8 +352,14 @@ class PythonPackageManager:
break
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 50)
call_pip(["install", "--upgrade", package_name, "--target", self.vendor_path])
self._create_list_from_pip()
try:
call_pip(
["install", "--upgrade", package_name, "--target", self.vendor_path]
)
self._create_list_from_pip()
except PipFailed as e:
FreeCAD.Console.PrintError(str(e) + "\n")
return
QtCore.QCoreApplication.processEvents(QtCore.QEventLoop.AllEvents, 50)
def _update_all_packages(self, package_list) -> None: