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