--- a/PipxInterface/Pipx.py Sat Sep 07 19:29:57 2024 +0200 +++ b/PipxInterface/Pipx.py Sun Sep 15 11:57:39 2024 +0200 @@ -8,25 +8,36 @@ """ import contextlib +import functools import json import os import pathlib import sysconfig -from PyQt6.QtCore import QObject, QProcess +from PyQt6.QtCore import QObject, QProcess, pyqtSignal from eric7 import Preferences from eric7.EricWidgets import EricMessageBox from eric7.SystemUtilities import OSUtilities +try: + from eric7.EricCore.EricProcess import EricProcess +except ImportError: + from .PipxProcess import PipxProcess as EricProcess + from .PipxExecDialog import PipxExecDialog class Pipx(QObject): """ - Class implementing the pip GUI logic. + Class implementing the pipx interface. + + @signal outdatedPackage(package:str, latestVer:str, oudatedDeps:bool) emitted with + the result of a check for outdated status of a package """ + outdatedPackage = pyqtSignal(str, str, bool) + def __init__(self, parent=None): """ Constructor @@ -38,6 +49,8 @@ self.__ui = parent + self.__pipxProcesses = [] + ############################################################################ ## Utility methods ############################################################################ @@ -49,7 +62,7 @@ @return string containing the pipx version number @rtype str """ - ok, output = self.runPipxProcess(["--version"]) + ok, output = self.__runPipxProcess(["--version"]) if ok: return output.strip() else: @@ -114,9 +127,9 @@ return pipx - def runPipxProcess(self, args): + def __runPipxProcess(self, args): """ - Public method to execute pipx with the given arguments. + Private method to execute pipx with the given arguments. @param args list of command line arguments for pipx @type list of str @@ -167,6 +180,106 @@ return pathlib.Path(jsonDict["__Path__"]) return jsonDict + def __runPipxAsyncProcess(self, args, callback=None, timeout=30000): + """ + Private method to execute pipx with the given arguments asynchronously. + + @param args list of command line arguments for pipx + @type list of str + @param callback reference to the function to be called a success flag and the + process output or error message (defaults to None) + @type function (optional) + @param timeout timeout for the process in milliseconds (defaults to 30000) + @type int (optional) + @return reference to the generated process object + @rtype QProcess + """ + process = EricProcess(timeout=timeout) + process.finished.connect( + functools.partial(self.__asyncProcessFinished, callback, process) + ) + process.errorOccurred.connect( + functools.partial(self.__asyncProcessError, process) + ) + self.__pipxProcesses.append(process) + process.start(self.__getPipxExecutable(), args) + return process + + def __asyncProcessError(self, process, error): + """ + Private method to handle a process error signal. + + @param process reference to the process + @type QProcess + @param error error that occurred + @type QProcess.ProcessError + """ + if error == QProcess.ProcessError.FailedToStart: + with contextlib.suppress(ValueError): + self.__pipxProcesses.remove(process) + EricMessageBox.critical( + None, self.tr("pipx Start Error"), self.tr("pipx could not be started.") + ) + else: + EricMessageBox.critical( + None, + self.tr("pipx Runtime Error"), + self.tr( + "<p>The pipx process reported an error.</p><p>Error: {0}</p>" + ).format(process.errorString()), + ) + + def __asyncProcessFinished(self, callback, process, _exitCode, exitStatus): + """ + Private method to handle the process finished signal. + + @param callback reference to the function to be called a success flag and the + process output or error message + @type function + @param process reference to the process + @type QProcess + @param _exitCode exit code of the process + @type int + @param exitStatus exit status of the process + @type QProcess.ExitStatus + """ + if process.timedOut(): + msg = self.tr("pipx did not finish within {0} seconds.").format( + process.timeoutInterval() // 1_000 + ) + if callback: + callback(False, msg) + else: + EricMessageBox.critical(None, self.tr("pipx Timeout Error"), msg) + + elif exitStatus == QProcess.ExitStatus.NormalExit: + ioEncoding = Preferences.getSystem("IOEncoding") + if process.exitCode() == 0: + output = str(process.readAllStandardOutput(), ioEncoding, "replace") + if callback: + callback(True, output) + else: + error = str(process.readAllStandardError(), ioEncoding, "replace") + msg = self.tr("<p>Message: {0}</p>").format(error) if error else "" + if callback: + callback( + False, + self.tr("<p>pipx exited with an error ({0}).</p>{1}").format( + process.exitCode(), msg + ), + ) + else: + EricMessageBox.critical( + None, + self.tr("pipx Execution Error"), + self.tr("<p>pipx exited with an error ({0}).</p>{1}").format( + process.exitCode(), msg + ), + ) + + with contextlib.suppress(ValueError): + self.__pipxProcesses.remove(process) + ############################################################################ ## pipx interpreter list function (modified from original to work here) ############################################################################ @@ -219,7 +332,7 @@ packages = [] - ok, output = self.runPipxProcess(["list", "--json"]) + ok, output = self.__runPipxProcess(["list", "--json"]) if ok and output: with contextlib.suppress(json.JSONDecodeError): data = json.loads(output, object_hook=self.__metadataDecoderHook) @@ -343,7 +456,7 @@ of failure @rtype tuple of (bool, str) """ - ok, output = self.runPipxProcess(["list", "--json"]) + ok, output = self.__runPipxProcess(["list", "--json"]) if ok: try: with open(specFile, "w") as f: @@ -522,15 +635,27 @@ @param package name of the package @type str - @return tuple containing the latest version in case the package is outdated - or None otherwise and a flag indicating any outdated dependencies - @rtype tuple of (str or None, bool) """ args = ["runpip", package, "list", "--outdated", "--format", "json"] if Preferences.getPip("PipSearchIndex"): indexUrl = Preferences.getPip("PipSearchIndex") + "/simple" args += ["--index-url", indexUrl] - ok, output = self.runPipxProcess(args) + self.__runPipxAsyncProcess( + args, callback=functools.partial(self.__checkPackageOutdatedCb, package) + ) + + def __checkPackageOutdatedCb(self, package, ok, output): + """ + Private method handling the pipx process output of a check for an outdated + package. + + @param package name of the package + @type str + @param ok flag indicating the process ended successfully + @type bool + @param output output of the pipx process or an error message + @type str + """ if not ok: EricMessageBox.information( None, @@ -540,15 +665,35 @@ "<p>Reason: {1}</p>" ).format(package, output), ) - return None, False + self.outdatedPackage.emit(package, "", False) + return outdatedList = json.loads(output) # check if the main package is in the list for outdatedPackage in outdatedList: if outdatedPackage["name"] == package: - return outdatedPackage["latest_version"], len(outdatedList) > 1 + self.outdatedPackage.emit( + package, outdatedPackage["latest_version"], len(outdatedList) > 1 + ) + return + + self.outdatedPackage.emit(package, "", bool(outdatedList)) + + def getPackageVersion(self, package): + """ + Public method to get the version of a package. - return None, bool(outdatedList) + @param package package name + @type str + @return package version + @rtype str + """ + packagesList = self.__getPackageDependencies(package=package) + for pack in packagesList: + if pack["name"] == package: + return pack["version"] + else: + return "" def __getPackageDependencies(self, package, uptodate=False, outdated=False): """ @@ -572,7 +717,7 @@ if Preferences.getPip("PipSearchIndex"): indexUrl = Preferences.getPip("PipSearchIndex") + "/simple" args += ["--index-url", indexUrl] - ok, output = self.runPipxProcess(args) + ok, output = self.__runPipxProcess(args) if not ok: EricMessageBox.information( None,