eric6/PluginManager/PluginInstallDialog.py

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

eric ide

mercurial