eric7/PluginManager/PluginInstallDialog.py

branch
eric7
changeset 8312
800c432b34c8
parent 8293
cad568796748
child 8318
962bce857696
equal deleted inserted replaced
8311:4e8b98454baa 8312:800c432b34c8
1 # -*- coding: utf-8 -*-
2
3 # Copyright (c) 2007 - 2021 Detlev Offenbach <detlev@die-offenbachs.de>
4 #
5
6 """
7 Module implementing the Plugin installation dialog.
8 """
9
10 import os
11 import sys
12 import shutil
13 import zipfile
14 import compileall
15 import glob
16 import contextlib
17 import urllib.parse
18
19 from PyQt5.QtCore import pyqtSlot, Qt, QDir, QFileInfo
20 from PyQt5.QtWidgets import (
21 QWidget, QDialogButtonBox, QAbstractButton, QApplication, QDialog,
22 QVBoxLayout
23 )
24
25 from E5Gui import E5FileDialog
26 from E5Gui.E5MainWindow import E5MainWindow
27
28 from .Ui_PluginInstallDialog import Ui_PluginInstallDialog
29
30 import Utilities
31 import Preferences
32
33 from Utilities.uic import compileUiFiles
34
35
36 class 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
585 class 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
623 class 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)

eric ide

mercurial