1# -*- coding: utf-8 -*-
2
3# Copyright (c) 2007 - 2021 Detlev Offenbach <detlev@die-offenbachs.de>
4#
5
6"""
7Module implementing the Plugin installation dialog.
8"""
9
10import os
11import sys
12import shutil
13import zipfile
14import compileall
15import glob
16import contextlib
17import urllib.parse
18
19from PyQt5.QtCore import pyqtSlot, Qt, QDir, QFileInfo
20from PyQt5.QtWidgets import (
21    QWidget, QDialogButtonBox, QAbstractButton, QApplication, QDialog,
22    QVBoxLayout
23)
24
25from E5Gui import E5FileDialog
26from E5Gui.E5MainWindow import E5MainWindow
27
28from .Ui_PluginInstallDialog import Ui_PluginInstallDialog
29
30import Utilities
31import Preferences
32
33from Utilities.uic import compileUiFiles
34
35
36class PluginInstallWidget(QWidget, Ui_PluginInstallDialog):
37    """
38    Class implementing the Plugin installation dialog.
39    """
40    def __init__(self, pluginManager, pluginFileNames, parent=None):
41        """
42        Constructor
43
44        @param pluginManager reference to the plugin manager object
45        @param pluginFileNames list of plugin files suggested for
46            installation (list of strings)
47        @param parent parent of this dialog (QWidget)
48        """
49        super().__init__(parent)
50        self.setupUi(self)
51
52        if pluginManager is None:
53            # started as external plugin installer
54            from .PluginManager import PluginManager
55            self.__pluginManager = PluginManager(doLoadPlugins=False)
56            self.__external = True
57        else:
58            self.__pluginManager = pluginManager
59            self.__external = False
60
61        self.__backButton = self.buttonBox.addButton(
62            self.tr("< Back"), QDialogButtonBox.ButtonRole.ActionRole)
63        self.__nextButton = self.buttonBox.addButton(
64            self.tr("Next >"), QDialogButtonBox.ButtonRole.ActionRole)
65        self.__finishButton = self.buttonBox.addButton(
66            self.tr("Install"), QDialogButtonBox.ButtonRole.ActionRole)
67
68        self.__closeButton = self.buttonBox.button(
69            QDialogButtonBox.StandardButton.Close)
70        self.__cancelButton = self.buttonBox.button(
71            QDialogButtonBox.StandardButton.Cancel)
72
73        userDir = self.__pluginManager.getPluginDir("user")
74        if userDir is not None:
75            self.destinationCombo.addItem(
76                self.tr("User plugins directory"),
77                userDir)
78
79        globalDir = self.__pluginManager.getPluginDir("global")
80        if globalDir is not None and os.access(globalDir, os.W_OK):
81            self.destinationCombo.addItem(
82                self.tr("Global plugins directory"),
83                globalDir)
84
85        self.__installedDirs = []
86        self.__installedFiles = []
87
88        self.__restartNeeded = False
89
90        downloadDir = QDir(Preferences.getPluginManager("DownloadPath"))
91        for pluginFileName in pluginFileNames:
92            fi = QFileInfo(pluginFileName)
93            if fi.isRelative():
94                pluginFileName = QFileInfo(
95                    downloadDir, fi.fileName()).absoluteFilePath()
96            self.archivesList.addItem(pluginFileName)
97            self.archivesList.sortItems()
98
99        self.__currentIndex = 0
100        self.__selectPage()
101
102    def restartNeeded(self):
103        """
104        Public method to check, if a restart of the IDE is required.
105
106        @return flag indicating a restart is required (boolean)
107        """
108        return self.__restartNeeded
109
110    def __createArchivesList(self):
111        """
112        Private method to create a list of plugin archive names.
113
114        @return list of plugin archive names (list of strings)
115        """
116        archivesList = []
117        for row in range(self.archivesList.count()):
118            archivesList.append(self.archivesList.item(row).text())
119        return archivesList
120
121    def __selectPage(self):
122        """
123        Private method to show the right wizard page.
124        """
125        self.wizard.setCurrentIndex(self.__currentIndex)
126        if self.__currentIndex == 0:
127            self.__backButton.setEnabled(False)
128            self.__nextButton.setEnabled(self.archivesList.count() > 0)
129            self.__finishButton.setEnabled(False)
130            self.__closeButton.hide()
131            self.__cancelButton.show()
132        elif self.__currentIndex == 1:
133            self.__backButton.setEnabled(True)
134            self.__nextButton.setEnabled(self.destinationCombo.count() > 0)
135            self.__finishButton.setEnabled(False)
136            self.__closeButton.hide()
137            self.__cancelButton.show()
138        else:
139            self.__backButton.setEnabled(True)
140            self.__nextButton.setEnabled(False)
141            self.__finishButton.setEnabled(True)
142            self.__closeButton.hide()
143            self.__cancelButton.show()
144
145            msg = self.tr(
146                "Plugin ZIP-Archives:\n{0}\n\nDestination:\n{1} ({2})"
147            ).format(
148                "\n".join(self.__createArchivesList()),
149                self.destinationCombo.currentText(),
150                self.destinationCombo.itemData(
151                    self.destinationCombo.currentIndex()
152                )
153            )
154            self.summaryEdit.setPlainText(msg)
155
156    @pyqtSlot()
157    def on_addArchivesButton_clicked(self):
158        """
159        Private slot to select plugin ZIP-archives via a file selection dialog.
160        """
161        dn = Preferences.getPluginManager("DownloadPath")
162        archives = E5FileDialog.getOpenFileNames(
163            self,
164            self.tr("Select plugin ZIP-archives"),
165            dn,
166            self.tr("Plugin archive (*.zip)"))
167
168        if archives:
169            matchflags = Qt.MatchFlag.MatchFixedString
170            if not Utilities.isWindowsPlatform():
171                matchflags |= Qt.MatchFlag.MatchCaseSensitive
172            for archive in archives:
173                if len(self.archivesList.findItems(archive, matchflags)) == 0:
174                    # entry not in list already
175                    self.archivesList.addItem(archive)
176            self.archivesList.sortItems()
177
178        self.__nextButton.setEnabled(self.archivesList.count() > 0)
179
180    @pyqtSlot()
181    def on_archivesList_itemSelectionChanged(self):
182        """
183        Private slot called, when the selection of the archives list changes.
184        """
185        self.removeArchivesButton.setEnabled(
186            len(self.archivesList.selectedItems()) > 0)
187
188    @pyqtSlot()
189    def on_removeArchivesButton_clicked(self):
190        """
191        Private slot to remove archives from the list.
192        """
193        for archiveItem in self.archivesList.selectedItems():
194            itm = self.archivesList.takeItem(
195                self.archivesList.row(archiveItem))
196            del itm
197
198        self.__nextButton.setEnabled(self.archivesList.count() > 0)
199
200    @pyqtSlot(QAbstractButton)
201    def on_buttonBox_clicked(self, button):
202        """
203        Private slot to handle the click of a button of the button box.
204
205        @param button reference to the button pressed (QAbstractButton)
206        """
207        if button == self.__backButton:
208            self.__currentIndex -= 1
209            self.__selectPage()
210        elif button == self.__nextButton:
211            self.__currentIndex += 1
212            self.__selectPage()
213        elif button == self.__finishButton:
214            self.__finishButton.setEnabled(False)
215            self.__installPlugins()
216            if not Preferences.getPluginManager("ActivateExternal"):
217                Preferences.setPluginManager("ActivateExternal", True)
218                self.__restartNeeded = True
219            self.__closeButton.show()
220            self.__cancelButton.hide()
221
222    def __installPlugins(self):
223        """
224        Private method to install the selected plugin archives.
225
226        @return flag indicating success (boolean)
227        """
228        res = True
229        self.summaryEdit.clear()
230        for archive in self.__createArchivesList():
231            self.summaryEdit.append(
232                self.tr("Installing {0} ...").format(archive))
233            ok, msg, restart = self.__installPlugin(archive)
234            res = res and ok
235            if ok:
236                self.summaryEdit.append(self.tr("  ok"))
237            else:
238                self.summaryEdit.append(msg)
239            if restart:
240                self.__restartNeeded = True
241        self.summaryEdit.append("\n")
242        if res:
243            self.summaryEdit.append(self.tr(
244                """The plugins were installed successfully."""))
245        else:
246            self.summaryEdit.append(self.tr(
247                """Some plugins could not be installed."""))
248
249        return res
250
251    def __installPlugin(self, archiveFilename):
252        """
253        Private slot to install the selected plugin.
254
255        @param archiveFilename name of the plugin archive
256            file (string)
257        @return flag indicating success (boolean), error message
258            upon failure (string) and flag indicating a restart
259            of the IDE is required (boolean)
260        """
261        installedPluginName = ""
262
263        archive = archiveFilename
264        destination = self.destinationCombo.itemData(
265            self.destinationCombo.currentIndex())
266
267        # check if archive is a local url
268        url = urllib.parse.urlparse(archive)
269        if url[0].lower() == 'file':
270            archive = url[2]
271
272        # check, if the archive exists
273        if not os.path.exists(archive):
274            return (
275                False,
276                self.tr(
277                    """<p>The archive file <b>{0}</b> does not exist. """
278                    """Aborting...</p>""").format(archive),
279                False
280            )
281
282        # check, if the archive is a valid zip file
283        if not zipfile.is_zipfile(archive):
284            return (
285                False,
286                self.tr(
287                    """<p>The file <b>{0}</b> is not a valid plugin """
288                    """ZIP-archive. Aborting...</p>""").format(archive),
289                False
290            )
291
292        # check, if the destination is writeable
293        if not os.access(destination, os.W_OK):
294            return (
295                False,
296                self.tr(
297                    """<p>The destination directory <b>{0}</b> is not """
298                    """writeable. Aborting...</p>""").format(destination),
299                False
300            )
301
302        zipFile = zipfile.ZipFile(archive, "r")
303
304        # check, if the archive contains a valid plugin
305        pluginFound = False
306        pluginFileName = ""
307        for name in zipFile.namelist():
308            if self.__pluginManager.isValidPluginName(name):
309                installedPluginName = name[:-3]
310                pluginFound = True
311                pluginFileName = name
312                break
313
314        if not pluginFound:
315            return (
316                False,
317                self.tr(
318                    """<p>The file <b>{0}</b> is not a valid plugin """
319                    """ZIP-archive. Aborting...</p>""").format(archive),
320                False
321            )
322
323        # parse the plugin module's plugin header
324        pluginSource = Utilities.decode(zipFile.read(pluginFileName))[0]
325        packageName = ""
326        internalPackages = []
327        needsRestart = False
328        pyqtApi = 0
329        doCompile = True
330        for line in pluginSource.splitlines():
331            if line.startswith("packageName"):
332                tokens = line.split("=")
333                if (
334                    tokens[0].strip() == "packageName" and
335                    tokens[1].strip()[1:-1] != "__core__"
336                ):
337                    if tokens[1].strip()[0] in ['"', "'"]:
338                        packageName = tokens[1].strip()[1:-1]
339                    else:
340                        if tokens[1].strip() == "None":
341                            packageName = "None"
342            elif line.startswith("internalPackages"):
343                tokens = line.split("=")
344                token = tokens[1].strip()[1:-1]
345                # it is a comma separated string
346                internalPackages = [p.strip() for p in token.split(",")]
347            elif line.startswith("needsRestart"):
348                tokens = line.split("=")
349                needsRestart = tokens[1].strip() == "True"
350            elif line.startswith("pyqtApi"):
351                tokens = line.split("=")
352                with contextlib.suppress(ValueError):
353                    pyqtApi = int(tokens[1].strip())
354            elif line.startswith("doNotCompile"):
355                tokens = line.split("=")
356                if tokens[1].strip() == "True":
357                    doCompile = False
358            elif line.startswith("# End-Of-Header"):
359                break
360
361        if not packageName:
362            return (
363                False,
364                self.tr(
365                    """<p>The plugin module <b>{0}</b> does not contain """
366                    """a 'packageName' attribute. Aborting...</p>"""
367                ).format(pluginFileName),
368                False
369            )
370
371        if pyqtApi < 2:
372            return (
373                False,
374                self.tr(
375                    """<p>The plugin module <b>{0}</b> does not conform"""
376                    """ with the PyQt v2 API. Aborting...</p>"""
377                ).format(pluginFileName),
378                False
379            )
380
381        # check, if it is a plugin, that collides with others
382        if (
383            not os.path.exists(os.path.join(destination, pluginFileName)) and
384            packageName != "None" and
385            os.path.exists(os.path.join(destination, packageName))
386        ):
387            return (
388                False,
389                self.tr("""<p>The plugin package <b>{0}</b> exists. """
390                        """Aborting...</p>""").format(
391                    os.path.join(destination, packageName)),
392                False
393            )
394
395        if (
396            os.path.exists(os.path.join(destination, pluginFileName)) and
397            packageName != "None" and
398            not os.path.exists(os.path.join(destination, packageName))
399        ):
400            return (
401                False,
402                self.tr("""<p>The plugin module <b>{0}</b> exists. """
403                        """Aborting...</p>""").format(
404                    os.path.join(destination, pluginFileName)),
405                False
406            )
407
408        activatePlugin = False
409        if not self.__external:
410            activatePlugin = (
411                not self.__pluginManager.isPluginLoaded(
412                    installedPluginName) or
413                (self.__pluginManager.isPluginLoaded(installedPluginName) and
414                 self.__pluginManager.isPluginActive(installedPluginName))
415            )
416            # try to unload a plugin with the same name
417            self.__pluginManager.unloadPlugin(installedPluginName)
418
419        # uninstall existing plug-in first to get clean conditions
420        if (
421            packageName != "None" and
422            not os.path.exists(
423                os.path.join(destination, packageName, "__init__.py"))
424        ):
425            # package directory contains just data, don't delete it
426            self.__uninstallPackage(destination, pluginFileName, "")
427        else:
428            self.__uninstallPackage(destination, pluginFileName, packageName)
429
430        # clean sys.modules
431        reload_ = self.__pluginManager.removePluginFromSysModules(
432            installedPluginName, packageName, internalPackages)
433
434        # now do the installation
435        self.__installedDirs = []
436        self.__installedFiles = []
437        try:
438            if packageName != "None":
439                namelist = sorted(zipFile.namelist())
440                tot = len(namelist)
441                self.progress.setMaximum(tot)
442                QApplication.processEvents()
443                for prog, name in enumerate(namelist):
444                    self.progress.setValue(prog)
445                    QApplication.processEvents()
446                    if (
447                        name == pluginFileName or
448                        name.startswith("{0}/".format(packageName)) or
449                        name.startswith("{0}\\".format(packageName))
450                    ):
451                        outname = name.replace("/", os.sep)
452                        outname = os.path.join(destination, outname)
453                        if outname.endswith("/") or outname.endswith("\\"):
454                            # it is a directory entry
455                            outname = outname[:-1]
456                            if not os.path.exists(outname):
457                                self.__makedirs(outname)
458                        else:
459                            # it is a file
460                            d = os.path.dirname(outname)
461                            if not os.path.exists(d):
462                                self.__makedirs(d)
463                            with open(outname, "wb") as f:
464                                f.write(zipFile.read(name))
465                            self.__installedFiles.append(outname)
466                self.progress.setValue(tot)
467                # now compile user interface files
468                compileUiFiles(os.path.join(destination, packageName), True)
469            else:
470                outname = os.path.join(destination, pluginFileName)
471                with open(outname, "w", encoding="utf-8") as f:
472                    f.write(pluginSource)
473                self.__installedFiles.append(outname)
474        except OSError as why:
475            self.__rollback()
476            return (
477                False,
478                self.tr("Error installing plugin. Reason: {0}")
479                .format(str(why)),
480                False
481            )
482        except Exception:
483            sys.stderr.write("Unspecific exception installing plugin.\n")
484            self.__rollback()
485            return (
486                False,
487                self.tr("Unspecific exception installing plugin."),
488                False
489            )
490
491        # now compile the plugins
492        if doCompile:
493            dirName = os.path.join(destination, packageName)
494            files = os.path.join(destination, pluginFileName)
495            os.path.join_unicode = False
496            compileall.compile_dir(dirName, quiet=True)
497            compileall.compile_file(files, quiet=True)
498            os.path.join_unicode = True
499
500            # now load and activate the plugin
501        self.__pluginManager.loadPlugin(
502            installedPluginName, destination, reload_=reload_, install=True)
503        if activatePlugin and not self.__external:
504            self.__pluginManager.activatePlugin(installedPluginName)
505
506        return True, "", needsRestart
507
508    def __rollback(self):
509        """
510        Private method to rollback a failed installation.
511        """
512        for fname in self.__installedFiles:
513            if os.path.exists(fname):
514                os.remove(fname)
515        for dname in self.__installedDirs:
516            if os.path.exists(dname):
517                shutil.rmtree(dname)
518
519    def __makedirs(self, name, mode=0o777):
520        """
521        Private method to create a directory and all intermediate ones.
522
523        This is an extended version of the Python one in order to
524        record the created directories.
525
526        @param name name of the directory to create (string)
527        @param mode permission to set for the new directory (integer)
528        """
529        head, tail = os.path.split(name)
530        if not tail:
531            head, tail = os.path.split(head)
532        if head and tail and not os.path.exists(head):
533            self.__makedirs(head, mode)
534            if tail == os.curdir:
535                # xxx/newdir/. exists if xxx/newdir exists
536                return
537        os.mkdir(name, mode)
538        self.__installedDirs.append(name)
539
540    def __uninstallPackage(self, destination, pluginFileName, packageName):
541        """
542        Private method to uninstall an already installed plugin to prepare
543        the update.
544
545        @param destination name of the plugin directory (string)
546        @param pluginFileName name of the plugin file (string)
547        @param packageName name of the plugin package (string)
548        """
549        packageDir = (
550            None
551            if packageName in ("", "None") else
552            os.path.join(destination, packageName)
553        )
554        pluginFile = os.path.join(destination, pluginFileName)
555
556        with contextlib.suppress(OSError, os.error):
557            if packageDir and os.path.exists(packageDir):
558                shutil.rmtree(packageDir)
559
560            fnameo = "{0}o".format(pluginFile)
561            if os.path.exists(fnameo):
562                os.remove(fnameo)
563
564            fnamec = "{0}c".format(pluginFile)
565            if os.path.exists(fnamec):
566                os.remove(fnamec)
567
568            pluginDirCache = os.path.join(
569                os.path.dirname(pluginFile), "__pycache__")
570            if os.path.exists(pluginDirCache):
571                pluginFileName = os.path.splitext(
572                    os.path.basename(pluginFile))[0]
573                for fnameo in glob.glob(
574                    os.path.join(pluginDirCache,
575                                 "{0}*.pyo".format(pluginFileName))):
576                    os.remove(fnameo)
577                for fnamec in glob.glob(
578                    os.path.join(pluginDirCache,
579                                 "{0}*.pyc".format(pluginFileName))):
580                    os.remove(fnamec)
581
582            os.remove(pluginFile)
583
584
585class PluginInstallDialog(QDialog):
586    """
587    Class for the dialog variant.
588    """
589    def __init__(self, pluginManager, pluginFileNames, parent=None):
590        """
591        Constructor
592
593        @param pluginManager reference to the plugin manager object
594        @param pluginFileNames list of plugin files suggested for
595            installation (list of strings)
596        @param parent reference to the parent widget (QWidget)
597        """
598        super().__init__(parent)
599        self.setSizeGripEnabled(True)
600
601        self.__layout = QVBoxLayout(self)
602        self.__layout.setContentsMargins(0, 0, 0, 0)
603        self.setLayout(self.__layout)
604
605        self.cw = PluginInstallWidget(pluginManager, pluginFileNames, self)
606        size = self.cw.size()
607        self.__layout.addWidget(self.cw)
608        self.resize(size)
609        self.setWindowTitle(self.cw.windowTitle())
610
611        self.cw.buttonBox.accepted.connect(self.accept)
612        self.cw.buttonBox.rejected.connect(self.reject)
613
614    def restartNeeded(self):
615        """
616        Public method to check, if a restart of the IDE is required.
617
618        @return flag indicating a restart is required (boolean)
619        """
620        return self.cw.restartNeeded()
621
622
623class PluginInstallWindow(E5MainWindow):
624    """
625    Main window class for the standalone dialog.
626    """
627    def __init__(self, pluginFileNames, parent=None):
628        """
629        Constructor
630
631        @param pluginFileNames list of plugin files suggested for
632            installation (list of strings)
633        @param parent reference to the parent widget (QWidget)
634        """
635        super().__init__(parent)
636        self.cw = PluginInstallWidget(None, pluginFileNames, self)
637        size = self.cw.size()
638        self.setCentralWidget(self.cw)
639        self.resize(size)
640        self.setWindowTitle(self.cw.windowTitle())
641
642        self.setStyle(Preferences.getUI("Style"),
643                      Preferences.getUI("StyleSheet"))
644
645        self.cw.buttonBox.accepted.connect(self.close)
646        self.cw.buttonBox.rejected.connect(self.close)
647