src/eric7/PluginManager/PluginInstallDialog.py

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

eric ide

mercurial