eric6/PluginManager/PluginInstallDialog.py

changeset 6942
2602857055c5
parent 6645
ad476851d7e0
child 7192
a22eee00b052
diff -r f99d60d6b59b -r 2602857055c5 eric6/PluginManager/PluginInstallDialog.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/eric6/PluginManager/PluginInstallDialog.py	Sun Apr 14 15:09:21 2019 +0200
@@ -0,0 +1,637 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2007 - 2019 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+"""
+Module implementing the Plugin installation dialog.
+"""
+
+from __future__ import unicode_literals
+
+import os
+import sys
+import shutil
+import zipfile
+import compileall
+import glob
+try:  # Py3
+    import urllib.parse as parse
+except (ImportError):
+    import urlparse as parse    # __IGNORE_WARNING__
+
+from PyQt5.QtCore import pyqtSlot, Qt, QDir, QFileInfo
+from PyQt5.QtWidgets import QWidget, QDialogButtonBox, QAbstractButton, \
+    QApplication, QDialog, QVBoxLayout
+
+from E5Gui import E5FileDialog
+from E5Gui.E5MainWindow import E5MainWindow
+
+from .Ui_PluginInstallDialog import Ui_PluginInstallDialog
+
+import Utilities
+import Preferences
+
+from Utilities.uic import compileUiFiles
+
+
+class PluginInstallWidget(QWidget, Ui_PluginInstallDialog):
+    """
+    Class implementing the Plugin installation dialog.
+    """
+    def __init__(self, pluginManager, pluginFileNames, parent=None):
+        """
+        Constructor
+        
+        @param pluginManager reference to the plugin manager object
+        @param pluginFileNames list of plugin files suggested for
+            installation (list of strings)
+        @param parent parent of this dialog (QWidget)
+        """
+        super(PluginInstallWidget, self).__init__(parent)
+        self.setupUi(self)
+        
+        if pluginManager is None:
+            # started as external plugin installer
+            from .PluginManager import PluginManager
+            self.__pluginManager = PluginManager(doLoadPlugins=False)
+            self.__external = True
+        else:
+            self.__pluginManager = pluginManager
+            self.__external = False
+        
+        self.__backButton = self.buttonBox.addButton(
+            self.tr("< Back"), QDialogButtonBox.ActionRole)
+        self.__nextButton = self.buttonBox.addButton(
+            self.tr("Next >"), QDialogButtonBox.ActionRole)
+        self.__finishButton = self.buttonBox.addButton(
+            self.tr("Install"), QDialogButtonBox.ActionRole)
+        
+        self.__closeButton = self.buttonBox.button(QDialogButtonBox.Close)
+        self.__cancelButton = self.buttonBox.button(QDialogButtonBox.Cancel)
+        
+        userDir = self.__pluginManager.getPluginDir("user")
+        if userDir is not None:
+            self.destinationCombo.addItem(
+                self.tr("User plugins directory"),
+                userDir)
+        
+        globalDir = self.__pluginManager.getPluginDir("global")
+        if globalDir is not None and os.access(globalDir, os.W_OK):
+            self.destinationCombo.addItem(
+                self.tr("Global plugins directory"),
+                globalDir)
+        
+        self.__installedDirs = []
+        self.__installedFiles = []
+        
+        self.__restartNeeded = False
+        
+        downloadDir = QDir(Preferences.getPluginManager("DownloadPath"))
+        for pluginFileName in pluginFileNames:
+            fi = QFileInfo(pluginFileName)
+            if fi.isRelative():
+                pluginFileName = QFileInfo(
+                    downloadDir, fi.fileName()).absoluteFilePath()
+            self.archivesList.addItem(pluginFileName)
+            self.archivesList.sortItems()
+        
+        self.__currentIndex = 0
+        self.__selectPage()
+    
+    def restartNeeded(self):
+        """
+        Public method to check, if a restart of the IDE is required.
+        
+        @return flag indicating a restart is required (boolean)
+        """
+        return self.__restartNeeded
+    
+    def __createArchivesList(self):
+        """
+        Private method to create a list of plugin archive names.
+        
+        @return list of plugin archive names (list of strings)
+        """
+        archivesList = []
+        for row in range(self.archivesList.count()):
+            archivesList.append(self.archivesList.item(row).text())
+        return archivesList
+
+    def __selectPage(self):
+        """
+        Private method to show the right wizard page.
+        """
+        self.wizard.setCurrentIndex(self.__currentIndex)
+        if self.__currentIndex == 0:
+            self.__backButton.setEnabled(False)
+            self.__nextButton.setEnabled(self.archivesList.count() > 0)
+            self.__finishButton.setEnabled(False)
+            self.__closeButton.hide()
+            self.__cancelButton.show()
+        elif self.__currentIndex == 1:
+            self.__backButton.setEnabled(True)
+            self.__nextButton.setEnabled(self.destinationCombo.count() > 0)
+            self.__finishButton.setEnabled(False)
+            self.__closeButton.hide()
+            self.__cancelButton.show()
+        else:
+            self.__backButton.setEnabled(True)
+            self.__nextButton.setEnabled(False)
+            self.__finishButton.setEnabled(True)
+            self.__closeButton.hide()
+            self.__cancelButton.show()
+            
+            msg = self.tr(
+                "Plugin ZIP-Archives:\n{0}\n\nDestination:\n{1} ({2})")\
+                .format("\n".join(self.__createArchivesList()),
+                        self.destinationCombo.currentText(),
+                        self.destinationCombo.itemData(
+                            self.destinationCombo.currentIndex())
+                        )
+            self.summaryEdit.setPlainText(msg)
+    
+    @pyqtSlot()
+    def on_addArchivesButton_clicked(self):
+        """
+        Private slot to select plugin ZIP-archives via a file selection dialog.
+        """
+        dn = Preferences.getPluginManager("DownloadPath")
+        archives = E5FileDialog.getOpenFileNames(
+            self,
+            self.tr("Select plugin ZIP-archives"),
+            dn,
+            self.tr("Plugin archive (*.zip)"))
+        
+        if archives:
+            matchflags = Qt.MatchFixedString
+            if not Utilities.isWindowsPlatform():
+                matchflags |= Qt.MatchCaseSensitive
+            for archive in archives:
+                if len(self.archivesList.findItems(archive, matchflags)) == 0:
+                    # entry not in list already
+                    self.archivesList.addItem(archive)
+            self.archivesList.sortItems()
+        
+        self.__nextButton.setEnabled(self.archivesList.count() > 0)
+    
+    @pyqtSlot()
+    def on_archivesList_itemSelectionChanged(self):
+        """
+        Private slot called, when the selection of the archives list changes.
+        """
+        self.removeArchivesButton.setEnabled(
+            len(self.archivesList.selectedItems()) > 0)
+    
+    @pyqtSlot()
+    def on_removeArchivesButton_clicked(self):
+        """
+        Private slot to remove archives from the list.
+        """
+        for archiveItem in self.archivesList.selectedItems():
+            itm = self.archivesList.takeItem(
+                self.archivesList.row(archiveItem))
+            del itm
+        
+        self.__nextButton.setEnabled(self.archivesList.count() > 0)
+    
+    @pyqtSlot(QAbstractButton)
+    def on_buttonBox_clicked(self, button):
+        """
+        Private slot to handle the click of a button of the button box.
+        
+        @param button reference to the button pressed (QAbstractButton)
+        """
+        if button == self.__backButton:
+            self.__currentIndex -= 1
+            self.__selectPage()
+        elif button == self.__nextButton:
+            self.__currentIndex += 1
+            self.__selectPage()
+        elif button == self.__finishButton:
+            self.__finishButton.setEnabled(False)
+            self.__installPlugins()
+            if not Preferences.getPluginManager("ActivateExternal"):
+                Preferences.setPluginManager("ActivateExternal", True)
+                self.__restartNeeded = True
+            self.__closeButton.show()
+            self.__cancelButton.hide()
+    
+    def __installPlugins(self):
+        """
+        Private method to install the selected plugin archives.
+        
+        @return flag indicating success (boolean)
+        """
+        res = True
+        self.summaryEdit.clear()
+        for archive in self.__createArchivesList():
+            self.summaryEdit.append(
+                self.tr("Installing {0} ...").format(archive))
+            ok, msg, restart = self.__installPlugin(archive)
+            res = res and ok
+            if ok:
+                self.summaryEdit.append(self.tr("  ok"))
+            else:
+                self.summaryEdit.append(msg)
+            if restart:
+                self.__restartNeeded = True
+        self.summaryEdit.append("\n")
+        if res:
+            self.summaryEdit.append(self.tr(
+                """The plugins were installed successfully."""))
+        else:
+            self.summaryEdit.append(self.tr(
+                """Some plugins could not be installed."""))
+        
+        return res
+    
+    def __installPlugin(self, archiveFilename):
+        """
+        Private slot to install the selected plugin.
+        
+        @param archiveFilename name of the plugin archive
+            file (string)
+        @return flag indicating success (boolean), error message
+            upon failure (string) and flag indicating a restart
+            of the IDE is required (boolean)
+        """
+        installedPluginName = ""
+        
+        archive = archiveFilename
+        destination = self.destinationCombo.itemData(
+            self.destinationCombo.currentIndex())
+        
+        # check if archive is a local url
+        url = parse.urlparse(archive)
+        if url[0].lower() == 'file':
+            archive = url[2]
+
+        # check, if the archive exists
+        if not os.path.exists(archive):
+            return False, \
+                self.tr(
+                    """<p>The archive file <b>{0}</b> does not exist. """
+                    """Aborting...</p>""").format(archive), \
+                False
+        
+        # check, if the archive is a valid zip file
+        if not zipfile.is_zipfile(archive):
+            return False, \
+                self.tr(
+                    """<p>The file <b>{0}</b> is not a valid plugin """
+                    """ZIP-archive. Aborting...</p>""").format(archive), \
+                False
+        
+        # check, if the destination is writeable
+        if not os.access(destination, os.W_OK):
+            return False, \
+                self.tr(
+                    """<p>The destination directory <b>{0}</b> is not """
+                    """writeable. Aborting...</p>""").format(destination), \
+                False
+        
+        zipFile = zipfile.ZipFile(archive, "r")
+        
+        # check, if the archive contains a valid plugin
+        pluginFound = False
+        pluginFileName = ""
+        for name in zipFile.namelist():
+            if self.__pluginManager.isValidPluginName(name):
+                installedPluginName = name[:-3]
+                pluginFound = True
+                pluginFileName = name
+                break
+        
+        if not pluginFound:
+            return False, \
+                self.tr(
+                    """<p>The file <b>{0}</b> is not a valid plugin """
+                    """ZIP-archive. Aborting...</p>""").format(archive), \
+                False
+        
+        # parse the plugin module's plugin header
+        pluginSource = Utilities.decode(zipFile.read(pluginFileName))[0]
+        packageName = ""
+        internalPackages = []
+        needsRestart = False
+        pyqtApi = 0
+        doCompile = True
+        for line in pluginSource.splitlines():
+            if line.startswith("packageName"):
+                tokens = line.split("=")
+                if tokens[0].strip() == "packageName" and \
+                   tokens[1].strip()[1:-1] != "__core__":
+                    if tokens[1].strip()[0] in ['"', "'"]:
+                        packageName = tokens[1].strip()[1:-1]
+                    else:
+                        if tokens[1].strip() == "None":
+                            packageName = "None"
+            elif line.startswith("internalPackages"):
+                tokens = line.split("=")
+                token = tokens[1].strip()[1:-1]
+                # it is a comma separated string
+                internalPackages = [p.strip() for p in token.split(",")]
+            elif line.startswith("needsRestart"):
+                tokens = line.split("=")
+                needsRestart = tokens[1].strip() == "True"
+            elif line.startswith("pyqtApi"):
+                tokens = line.split("=")
+                try:
+                    pyqtApi = int(tokens[1].strip())
+                except ValueError:
+                    pass
+            elif line.startswith("doNotCompile"):
+                tokens = line.split("=")
+                if tokens[1].strip() == "True":
+                    doCompile = False
+            elif line.startswith("# End-Of-Header"):
+                break
+        
+        if not packageName:
+            return False, \
+                self.tr(
+                    """<p>The plugin module <b>{0}</b> does not contain """
+                    """a 'packageName' attribute. Aborting...</p>""")\
+                .format(pluginFileName), \
+                False
+        
+        if pyqtApi < 2:
+            return False, \
+                self.tr(
+                    """<p>The plugin module <b>{0}</b> does not conform"""
+                    """ with the PyQt v2 API. Aborting...</p>""")\
+                .format(pluginFileName), \
+                False
+        
+        # check, if it is a plugin, that collides with others
+        if not os.path.exists(os.path.join(destination, pluginFileName)) and \
+           packageName != "None" and \
+           os.path.exists(os.path.join(destination, packageName)):
+            return False, \
+                self.tr("""<p>The plugin package <b>{0}</b> exists. """
+                        """Aborting...</p>""")\
+                    .format(os.path.join(destination, packageName)), \
+                False
+        
+        if os.path.exists(os.path.join(destination, pluginFileName)) and \
+           packageName != "None" and \
+           not os.path.exists(os.path.join(destination, packageName)):
+            return False, \
+                self.tr("""<p>The plugin module <b>{0}</b> exists. """
+                        """Aborting...</p>""")\
+                    .format(os.path.join(destination, pluginFileName)), \
+                False
+        
+        activatePlugin = False
+        if not self.__external:
+            activatePlugin = \
+                not self.__pluginManager.isPluginLoaded(
+                    installedPluginName) or \
+                (self.__pluginManager.isPluginLoaded(installedPluginName) and
+                 self.__pluginManager.isPluginActive(installedPluginName))
+            # try to unload a plugin with the same name
+            self.__pluginManager.unloadPlugin(installedPluginName)
+        
+        # uninstall existing plug-in first to get clean conditions
+        if packageName != "None" and \
+            not os.path.exists(
+                os.path.join(destination, packageName, "__init__.py")):
+            # package directory contains just data, don't delete it
+            self.__uninstallPackage(destination, pluginFileName, "")
+        else:
+            self.__uninstallPackage(destination, pluginFileName, packageName)
+        
+        # clean sys.modules
+        reload_ = self.__pluginManager.removePluginFromSysModules(
+            installedPluginName, packageName, internalPackages)
+        
+        # now do the installation
+        self.__installedDirs = []
+        self.__installedFiles = []
+        try:
+            if packageName != "None":
+                namelist = sorted(zipFile.namelist())
+                tot = len(namelist)
+                prog = 0
+                self.progress.setMaximum(tot)
+                QApplication.processEvents()
+                for name in namelist:
+                    self.progress.setValue(prog)
+                    QApplication.processEvents()
+                    prog += 1
+                    if name == pluginFileName or \
+                       name.startswith("{0}/".format(packageName)) or \
+                       name.startswith("{0}\\".format(packageName)):
+                        outname = name.replace("/", os.sep)
+                        outname = os.path.join(destination, outname)
+                        if outname.endswith("/") or outname.endswith("\\"):
+                            # it is a directory entry
+                            outname = outname[:-1]
+                            if not os.path.exists(outname):
+                                self.__makedirs(outname)
+                        else:
+                            # it is a file
+                            d = os.path.dirname(outname)
+                            if not os.path.exists(d):
+                                self.__makedirs(d)
+                            f = open(outname, "wb")
+                            f.write(zipFile.read(name))
+                            f.close()
+                            self.__installedFiles.append(outname)
+                self.progress.setValue(tot)
+                # now compile user interface files
+                compileUiFiles(os.path.join(destination, packageName), True)
+            else:
+                outname = os.path.join(destination, pluginFileName)
+                f = open(outname, "w", encoding="utf-8")
+                f.write(pluginSource)
+                f.close()
+                self.__installedFiles.append(outname)
+        except os.error as why:
+            self.__rollback()
+            return False, \
+                self.tr(
+                    "Error installing plugin. Reason: {0}").format(str(why)), \
+                False
+        except IOError as why:
+            self.__rollback()
+            return False, \
+                self.tr(
+                    "Error installing plugin. Reason: {0}").format(str(why)), \
+                False
+        except OSError as why:
+            self.__rollback()
+            return False, \
+                self.tr(
+                    "Error installing plugin. Reason: {0}").format(str(why)), \
+                False
+        except Exception:
+            sys.stderr.write("Unspecific exception installing plugin.\n")
+            self.__rollback()
+            return False, \
+                self.tr("Unspecific exception installing plugin."), \
+                False
+        
+        # now compile the plugins
+        if doCompile:
+            dirName = os.path.join(destination, packageName)
+            files = os.path.join(destination, pluginFileName)
+            if sys.version_info[0] == 2:
+                dirName = dirName.encode(sys.getfilesystemencoding())
+                files = files.encode(sys.getfilesystemencoding())
+            os.path.join_unicode = False
+            compileall.compile_dir(dirName, quiet=True)
+            compileall.compile_file(files, quiet=True)
+            os.path.join_unicode = True
+        
+        if not self.__external:
+            # now load and activate the plugin
+            self.__pluginManager.loadPlugin(installedPluginName, destination,
+                                            reload_)
+            if activatePlugin:
+                self.__pluginManager.activatePlugin(installedPluginName)
+        
+        return True, "", needsRestart
+    
+    def __rollback(self):
+        """
+        Private method to rollback a failed installation.
+        """
+        for fname in self.__installedFiles:
+            if os.path.exists(fname):
+                os.remove(fname)
+        for dname in self.__installedDirs:
+            if os.path.exists(dname):
+                shutil.rmtree(dname)
+    
+    def __makedirs(self, name, mode=0o777):
+        """
+        Private method to create a directory and all intermediate ones.
+        
+        This is an extended version of the Python one in order to
+        record the created directories.
+        
+        @param name name of the directory to create (string)
+        @param mode permission to set for the new directory (integer)
+        """
+        head, tail = os.path.split(name)
+        if not tail:
+            head, tail = os.path.split(head)
+        if head and tail and not os.path.exists(head):
+            self.__makedirs(head, mode)
+            if tail == os.curdir:
+                # xxx/newdir/. exists if xxx/newdir exists
+                return
+        os.mkdir(name, mode)
+        self.__installedDirs.append(name)
+    
+    def __uninstallPackage(self, destination, pluginFileName, packageName):
+        """
+        Private method to uninstall an already installed plugin to prepare
+        the update.
+        
+        @param destination name of the plugin directory (string)
+        @param pluginFileName name of the plugin file (string)
+        @param packageName name of the plugin package (string)
+        """
+        if packageName == "" or packageName == "None":
+            packageDir = None
+        else:
+            packageDir = os.path.join(destination, packageName)
+        pluginFile = os.path.join(destination, pluginFileName)
+        
+        try:
+            if packageDir and os.path.exists(packageDir):
+                shutil.rmtree(packageDir)
+            
+            fnameo = "{0}o".format(pluginFile)
+            if os.path.exists(fnameo):
+                os.remove(fnameo)
+            
+            fnamec = "{0}c".format(pluginFile)
+            if os.path.exists(fnamec):
+                os.remove(fnamec)
+            
+            pluginDirCache = os.path.join(
+                os.path.dirname(pluginFile), "__pycache__")
+            if os.path.exists(pluginDirCache):
+                pluginFileName = os.path.splitext(
+                    os.path.basename(pluginFile))[0]
+                for fnameo in glob.glob(
+                    os.path.join(pluginDirCache,
+                                 "{0}*.pyo".format(pluginFileName))):
+                    os.remove(fnameo)
+                for fnamec in glob.glob(
+                    os.path.join(pluginDirCache,
+                                 "{0}*.pyc".format(pluginFileName))):
+                    os.remove(fnamec)
+            
+            os.remove(pluginFile)
+        except (IOError, OSError, os.error):
+            # ignore some exceptions
+            pass
+
+
+class PluginInstallDialog(QDialog):
+    """
+    Class for the dialog variant.
+    """
+    def __init__(self, pluginManager, pluginFileNames, parent=None):
+        """
+        Constructor
+        
+        @param pluginManager reference to the plugin manager object
+        @param pluginFileNames list of plugin files suggested for
+            installation (list of strings)
+        @param parent reference to the parent widget (QWidget)
+        """
+        super(PluginInstallDialog, self).__init__(parent)
+        self.setSizeGripEnabled(True)
+        
+        self.__layout = QVBoxLayout(self)
+        self.__layout.setContentsMargins(0, 0, 0, 0)
+        self.setLayout(self.__layout)
+        
+        self.cw = PluginInstallWidget(pluginManager, pluginFileNames, self)
+        size = self.cw.size()
+        self.__layout.addWidget(self.cw)
+        self.resize(size)
+        self.setWindowTitle(self.cw.windowTitle())
+        
+        self.cw.buttonBox.accepted.connect(self.accept)
+        self.cw.buttonBox.rejected.connect(self.reject)
+    
+    def restartNeeded(self):
+        """
+        Public method to check, if a restart of the IDE is required.
+        
+        @return flag indicating a restart is required (boolean)
+        """
+        return self.cw.restartNeeded()
+
+
+class PluginInstallWindow(E5MainWindow):
+    """
+    Main window class for the standalone dialog.
+    """
+    def __init__(self, pluginFileNames, parent=None):
+        """
+        Constructor
+        
+        @param pluginFileNames list of plugin files suggested for
+            installation (list of strings)
+        @param parent reference to the parent widget (QWidget)
+        """
+        super(PluginInstallWindow, self).__init__(parent)
+        self.cw = PluginInstallWidget(None, pluginFileNames, self)
+        size = self.cw.size()
+        self.setCentralWidget(self.cw)
+        self.resize(size)
+        self.setWindowTitle(self.cw.windowTitle())
+        
+        self.setStyle(Preferences.getUI("Style"),
+                      Preferences.getUI("StyleSheet"))
+        
+        self.cw.buttonBox.accepted.connect(self.close)
+        self.cw.buttonBox.rejected.connect(self.close)

eric ide

mercurial