1# -*- coding: utf-8 -*- 2 3# Copyright (c) 2007 - 2021 Detlev Offenbach <detlev@die-offenbachs.de> 4# 5 6""" 7Module implementing the Plugin Manager. 8""" 9 10import os 11import sys 12import zipfile 13import types 14import importlib 15import contextlib 16import logging 17 18from PyQt5.QtCore import ( 19 pyqtSignal, QObject, QDate, QFile, QFileInfo, QUrl, QIODevice 20) 21from PyQt5.QtGui import QPixmap 22from PyQt5.QtNetwork import ( 23 QNetworkAccessManager, QNetworkRequest, QNetworkReply 24) 25 26from E5Gui import E5MessageBox 27from E5Gui.E5Application import e5App 28 29from E5Network.E5NetworkProxyFactory import proxyAuthenticationRequired 30try: 31 from E5Network.E5SslErrorHandler import E5SslErrorHandler, E5SslErrorState 32 SSL_AVAILABLE = True 33except ImportError: 34 SSL_AVAILABLE = False 35 36from .PluginExceptions import ( 37 PluginPathError, PluginModulesError, PluginLoadError, 38 PluginActivationError, PluginModuleFormatError, PluginClassFormatError 39) 40 41import UI.PixmapCache 42 43import Globals 44import Utilities 45import Preferences 46 47from eric6config import getConfig 48 49 50class PluginManager(QObject): 51 """ 52 Class implementing the Plugin Manager. 53 54 @signal shutdown() emitted at shutdown of the IDE 55 @signal pluginAboutToBeActivated(modulName, pluginObject) emitted just 56 before a plugin is activated 57 @signal pluginActivated(moduleName, pluginObject) emitted just after 58 a plugin was activated 59 @signal allPlugginsActivated() emitted at startup after all plugins have 60 been activated 61 @signal pluginAboutToBeDeactivated(moduleName, pluginObject) emitted just 62 before a plugin is deactivated 63 @signal pluginDeactivated(moduleName, pluginObject) emitted just after 64 a plugin was deactivated 65 """ 66 shutdown = pyqtSignal() 67 pluginAboutToBeActivated = pyqtSignal(str, object) 68 pluginActivated = pyqtSignal(str, object) 69 allPlugginsActivated = pyqtSignal() 70 pluginAboutToBeDeactivated = pyqtSignal(str, object) 71 pluginDeactivated = pyqtSignal(str, object) 72 73 def __init__(self, parent=None, disabledPlugins=None, doLoadPlugins=True, 74 develPlugin=None): 75 """ 76 Constructor 77 78 The Plugin Manager deals with three different plugin directories. 79 The first is the one, that is part of eric6 (eric6/Plugins). The 80 second one is the global plugin directory called 'eric6plugins', 81 which is located inside the site-packages directory. The last one 82 is the user plugin directory located inside the .eric6 directory 83 of the users home directory. 84 85 @param parent reference to the parent object 86 @type QObject 87 @param disabledPlugins list of plug-ins that have been disabled via 88 the command line parameters '--disable-plugin=' 89 @type list of str 90 @param doLoadPlugins flag indicating, that plug-ins should 91 be loaded 92 @type bool 93 @param develPlugin filename of a plug-in to be loaded for 94 development 95 @type str 96 @exception PluginPathError raised to indicate an invalid plug-in path 97 @exception PluginModulesError raised to indicate the absence of 98 plug-in modules 99 """ 100 super().__init__(parent) 101 102 self.__ui = parent 103 self.__develPluginFile = develPlugin 104 self.__develPluginName = None 105 if disabledPlugins is not None: 106 self.__disabledPlugins = disabledPlugins[:] 107 else: 108 self.__disabledPlugins = [] 109 110 self.__inactivePluginsKey = "PluginManager/InactivePlugins" 111 112 self.pluginDirs = { 113 "eric6": os.path.join(getConfig('ericDir'), "Plugins"), 114 "global": os.path.join(Utilities.getPythonLibraryDirectory(), 115 "eric6plugins"), 116 "user": os.path.join(Utilities.getConfigDir(), "eric6plugins"), 117 } 118 self.__priorityOrder = ["eric6", "global", "user"] 119 120 self.__defaultDownloadDir = os.path.join( 121 Utilities.getConfigDir(), "Downloads") 122 123 self.__activePlugins = {} 124 self.__inactivePlugins = {} 125 self.__onDemandActivePlugins = {} 126 self.__onDemandInactivePlugins = {} 127 self.__activeModules = {} 128 self.__inactiveModules = {} 129 self.__onDemandActiveModules = {} 130 self.__onDemandInactiveModules = {} 131 self.__failedModules = {} 132 133 self.__foundCoreModules = [] 134 self.__foundGlobalModules = [] 135 self.__foundUserModules = [] 136 137 self.__modulesCount = 0 138 139 pdirsExist, msg = self.__pluginDirectoriesExist() 140 if not pdirsExist: 141 raise PluginPathError(msg) 142 143 if doLoadPlugins: 144 if not self.__pluginModulesExist(): 145 raise PluginModulesError 146 147 self.__insertPluginsPaths() 148 149 self.__loadPlugins() 150 151 self.__checkPluginsDownloadDirectory() 152 153 self.pluginRepositoryFile = os.path.join(Utilities.getConfigDir(), 154 "PluginRepository") 155 156 # attributes for the network objects 157 self.__networkManager = QNetworkAccessManager(self) 158 self.__networkManager.proxyAuthenticationRequired.connect( 159 proxyAuthenticationRequired) 160 if SSL_AVAILABLE: 161 self.__sslErrorHandler = E5SslErrorHandler(self) 162 self.__networkManager.sslErrors.connect(self.__sslErrors) 163 self.__replies = [] 164 165 with contextlib.suppress(AttributeError): 166 self.__ui.onlineStateChanged.connect(self.__onlineStateChanged) 167 168 def finalizeSetup(self): 169 """ 170 Public method to finalize the setup of the plugin manager. 171 """ 172 for module in ( 173 list(self.__onDemandInactiveModules.values()) + 174 list(self.__onDemandActiveModules.values()) 175 ): 176 if hasattr(module, "moduleSetup"): 177 module.moduleSetup() 178 179 def getPluginDir(self, key): 180 """ 181 Public method to get the path of a plugin directory. 182 183 @param key key of the plug-in directory (string) 184 @return path of the requested plugin directory (string) 185 """ 186 if key not in ["global", "user"]: 187 return None 188 else: 189 try: 190 return self.pluginDirs[key] 191 except KeyError: 192 return None 193 194 def __pluginDirectoriesExist(self): 195 """ 196 Private method to check, if the plugin folders exist. 197 198 If the plugin folders don't exist, they are created (if possible). 199 200 @return tuple of a flag indicating existence of any of the plugin 201 directories (boolean) and a message (string) 202 """ 203 if self.__develPluginFile: 204 path = Utilities.splitPath(self.__develPluginFile)[0] 205 fname = os.path.join(path, "__init__.py") 206 if not os.path.exists(fname): 207 try: 208 with open(fname, "w"): 209 pass 210 except OSError: 211 return ( 212 False, 213 self.tr("Could not create a package for {0}.") 214 .format(self.__develPluginFile)) 215 216 fname = os.path.join(self.pluginDirs["user"], "__init__.py") 217 if not os.path.exists(fname): 218 if not os.path.exists(self.pluginDirs["user"]): 219 os.mkdir(self.pluginDirs["user"], 0o755) 220 try: 221 with open(fname, "w"): 222 pass 223 except OSError: 224 del self.pluginDirs["user"] 225 226 if not os.path.exists(self.pluginDirs["global"]): 227 try: 228 # create the global plugins directory 229 os.mkdir(self.pluginDirs["global"], 0o755) 230 fname = os.path.join(self.pluginDirs["global"], "__init__.py") 231 with open(fname, "w", encoding="utf-8") as f: 232 f.write('# -*- coding: utf-8 -*-' + "\n") 233 f.write("\n") 234 f.write('"""' + "\n") 235 f.write('Package containing the global plugins.' + "\n") 236 f.write('"""' + "\n") 237 except OSError: 238 del self.pluginDirs["global"] 239 240 if not os.path.exists(self.pluginDirs["eric6"]): 241 return ( 242 False, 243 self.tr( 244 "The internal plugin directory <b>{0}</b>" 245 " does not exits.").format(self.pluginDirs["eric6"])) 246 247 return (True, "") 248 249 def __pluginModulesExist(self): 250 """ 251 Private method to check, if there are plugins available. 252 253 @return flag indicating the availability of plugins (boolean) 254 """ 255 if ( 256 self.__develPluginFile and 257 not os.path.exists(self.__develPluginFile) 258 ): 259 return False 260 261 self.__foundCoreModules = self.getPluginModules( 262 self.pluginDirs["eric6"]) 263 if Preferences.getPluginManager("ActivateExternal"): 264 if "global" in self.pluginDirs: 265 self.__foundGlobalModules = self.getPluginModules( 266 self.pluginDirs["global"]) 267 if "user" in self.pluginDirs: 268 self.__foundUserModules = self.getPluginModules( 269 self.pluginDirs["user"]) 270 271 return len(self.__foundCoreModules + self.__foundGlobalModules + 272 self.__foundUserModules) > 0 273 274 def getPluginModules(self, pluginPath): 275 """ 276 Public method to get a list of plugin modules. 277 278 @param pluginPath name of the path to search (string) 279 @return list of plugin module names (list of string) 280 """ 281 pluginFiles = [f[:-3] for f in os.listdir(pluginPath) 282 if self.isValidPluginName(f)] 283 return pluginFiles[:] 284 285 def isValidPluginName(self, pluginName): 286 """ 287 Public methode to check, if a file name is a valid plugin name. 288 289 Plugin modules must start with "Plugin" and have the extension ".py". 290 291 @param pluginName name of the file to be checked (string) 292 @return flag indicating a valid plugin name (boolean) 293 """ 294 return pluginName.startswith("Plugin") and pluginName.endswith(".py") 295 296 def __insertPluginsPaths(self): 297 """ 298 Private method to insert the valid plugin paths intos the search path. 299 """ 300 for key in self.__priorityOrder: 301 if key in self.pluginDirs: 302 if self.pluginDirs[key] not in sys.path: 303 sys.path.insert(2, self.pluginDirs[key]) 304 UI.PixmapCache.addSearchPath(self.pluginDirs[key]) 305 306 if self.__develPluginFile: 307 path = Utilities.splitPath(self.__develPluginFile)[0] 308 if path not in sys.path: 309 sys.path.insert(2, path) 310 UI.PixmapCache.addSearchPath(path) 311 312 def __loadPlugins(self): 313 """ 314 Private method to load the plugins found. 315 """ 316 develPluginName = "" 317 if self.__develPluginFile: 318 develPluginPath, develPluginName = Utilities.splitPath( 319 self.__develPluginFile) 320 if self.isValidPluginName(develPluginName): 321 develPluginName = develPluginName[:-3] 322 323 for pluginName in self.__foundGlobalModules: 324 # user and core plug-ins have priority 325 if ( 326 pluginName not in self.__foundUserModules and 327 pluginName not in self.__foundCoreModules and 328 pluginName != develPluginName 329 ): 330 self.loadPlugin(pluginName, self.pluginDirs["global"]) 331 332 for pluginName in self.__foundUserModules: 333 # core plug-ins have priority 334 if ( 335 pluginName not in self.__foundCoreModules and 336 pluginName != develPluginName 337 ): 338 self.loadPlugin(pluginName, self.pluginDirs["user"]) 339 340 for pluginName in self.__foundCoreModules: 341 # plug-in under development has priority 342 if pluginName != develPluginName: 343 self.loadPlugin(pluginName, self.pluginDirs["eric6"]) 344 345 if develPluginName: 346 self.loadPlugin(develPluginName, develPluginPath) 347 self.__develPluginName = develPluginName 348 349 def loadDocumentationSetPlugins(self): 350 """ 351 Public method to load just the documentation sets plugins. 352 353 @exception PluginModulesError raised to indicate the absence of 354 plug-in modules 355 """ 356 if not self.__pluginModulesExist(): 357 raise PluginModulesError 358 359 self.__insertPluginsPaths() 360 361 for pluginName in self.__foundGlobalModules: 362 # user and core plug-ins have priority 363 if ( 364 pluginName not in self.__foundUserModules and 365 pluginName not in self.__foundCoreModules and 366 pluginName.startswith("PluginDocumentationSets") 367 ): 368 self.loadPlugin(pluginName, self.pluginDirs["global"]) 369 370 for pluginName in self.__foundUserModules: 371 # core plug-ins have priority 372 if ( 373 pluginName not in self.__foundCoreModules and 374 pluginName.startswith("PluginDocumentationSets") 375 ): 376 self.loadPlugin(pluginName, self.pluginDirs["user"]) 377 378 for pluginName in self.__foundCoreModules: 379 # plug-in under development has priority 380 if pluginName.startswith("PluginDocumentationSets"): 381 self.loadPlugin(pluginName, self.pluginDirs["eric6"]) 382 383 def loadPlugin(self, name, directory, reload_=False, install=False): 384 """ 385 Public method to load a plugin module. 386 387 Initially all modules are inactive. Modules that are requested on 388 demand are sorted out and are added to the on demand list. Some 389 basic validity checks are performed as well. Modules failing these 390 checks are added to the failed modules list. 391 392 @param name name of the module to be loaded 393 @type str 394 @param directory name of the plugin directory 395 @type str 396 @param reload_ flag indicating to reload the module 397 @type bool 398 @param install flag indicating a load operation as part of an 399 installation process 400 @type bool 401 @exception PluginLoadError raised to indicate an issue loading 402 the plug-in 403 """ 404 try: 405 fname = "{0}.py".format(os.path.join(directory, name)) 406 spec = importlib.util.spec_from_file_location(name, fname) 407 module = importlib.util.module_from_spec(spec) 408 sys.modules[module.__name__] = module 409 spec.loader.exec_module(module) 410 if not hasattr(module, "autoactivate"): 411 module.error = self.tr( 412 "Module is missing the 'autoactivate' attribute.") 413 logging.debug( 414 "{0}: Module is missing the 'autoactivate' attribute." 415 .format(name) 416 ) 417 self.__failedModules[name] = module 418 raise PluginLoadError(name) 419 if getattr(module, "autoactivate", False): 420 self.__inactiveModules[name] = module 421 else: 422 if ( 423 not hasattr(module, "pluginType") or 424 not hasattr(module, "pluginTypename") 425 ): 426 module.error = self.tr( 427 "Module is missing the 'pluginType' " 428 "and/or 'pluginTypename' attributes." 429 ) 430 logging.debug( 431 "{0}: Module is missing the 'pluginType' " 432 "and/or 'pluginTypename' attributes." 433 .format(name) 434 ) 435 self.__failedModules[name] = module 436 raise PluginLoadError(name) 437 else: 438 self.__onDemandInactiveModules[name] = module 439 module.eric6PluginModuleName = name 440 module.eric6PluginModuleFilename = fname 441 if install and hasattr(module, "installDependencies"): 442 # ask the module to install its dependencies 443 module.installDependencies(self.pipInstall) 444 self.__modulesCount += 1 445 if reload_: 446 importlib.reload(module) 447 self.initOnDemandPlugin(name) 448 with contextlib.suppress(KeyError, AttributeError): 449 pluginObject = self.__onDemandInactivePlugins[name] 450 pluginObject.initToolbar( 451 self.__ui, e5App().getObject("ToolbarManager")) 452 except PluginLoadError: 453 print("Error loading plug-in module:", name) 454 except Exception as err: 455 module = types.ModuleType(name) 456 module.error = self.tr( 457 "Module failed to load. Error: {0}").format(str(err)) 458 logging.debug( 459 "{0}: Module failed to load. Error: {1}" 460 .format(name, str(err)) 461 ) 462 self.__failedModules[name] = module 463 print("Error loading plug-in module:", name) 464 print(str(err)) 465 466 def unloadPlugin(self, name): 467 """ 468 Public method to unload a plugin module. 469 470 @param name name of the module to be unloaded (string) 471 @return flag indicating success (boolean) 472 """ 473 if name in self.__onDemandActiveModules: 474 # cannot unload an ondemand plugin, that is in use 475 return False 476 477 if name in self.__activeModules: 478 self.deactivatePlugin(name) 479 480 if name in self.__inactiveModules: 481 with contextlib.suppress(KeyError): 482 pluginObject = self.__inactivePlugins[name] 483 with contextlib.suppress(AttributeError): 484 pluginObject.prepareUnload() 485 del self.__inactivePlugins[name] 486 del self.__inactiveModules[name] 487 elif name in self.__onDemandInactiveModules: 488 with contextlib.suppress(KeyError): 489 pluginObject = self.__onDemandInactivePlugins[name] 490 with contextlib.suppress(AttributeError): 491 pluginObject.prepareUnload() 492 del self.__onDemandInactivePlugins[name] 493 del self.__onDemandInactiveModules[name] 494 elif name in self.__failedModules: 495 del self.__failedModules[name] 496 497 self.__modulesCount -= 1 498 return True 499 500 def removePluginFromSysModules(self, pluginName, package, 501 internalPackages): 502 """ 503 Public method to remove a plugin and all related modules from 504 sys.modules. 505 506 @param pluginName name of the plugin module (string) 507 @param package name of the plugin package (string) 508 @param internalPackages list of intenal packages (list of string) 509 @return flag indicating the plugin module was found in sys.modules 510 (boolean) 511 """ 512 packages = [package] + internalPackages 513 found = False 514 if not package: 515 package = "__None__" 516 for moduleName in list(sys.modules.keys())[:]: 517 if ( 518 moduleName == pluginName or 519 moduleName.split(".")[0] in packages 520 ): 521 found = True 522 del sys.modules[moduleName] 523 return found 524 525 def initOnDemandPlugins(self): 526 """ 527 Public method to create plugin objects for all on demand plugins. 528 529 Note: The plugins are not activated. 530 """ 531 names = sorted(self.__onDemandInactiveModules.keys()) 532 for name in names: 533 self.initOnDemandPlugin(name) 534 535 def initOnDemandPlugin(self, name): 536 """ 537 Public method to create a plugin object for the named on demand plugin. 538 539 Note: The plug-in is not activated. 540 541 @param name name of the plug-in (string) 542 @exception PluginActivationError raised to indicate an issue during the 543 plug-in activation 544 """ 545 try: 546 try: 547 module = self.__onDemandInactiveModules[name] 548 except KeyError: 549 return 550 551 if not self.__canActivatePlugin(module): 552 raise PluginActivationError(module.eric6PluginModuleName) 553 version = getattr(module, "version", "0.0.0") 554 className = getattr(module, "className", "") 555 pluginClass = getattr(module, className) 556 pluginObject = None 557 if name not in self.__onDemandInactivePlugins: 558 pluginObject = pluginClass(self.__ui) 559 pluginObject.eric6PluginModule = module 560 pluginObject.eric6PluginName = className 561 pluginObject.eric6PluginVersion = version 562 self.__onDemandInactivePlugins[name] = pluginObject 563 except PluginActivationError: 564 return 565 566 def initPluginToolbars(self, toolbarManager): 567 """ 568 Public method to initialize plug-in toolbars. 569 570 @param toolbarManager reference to the toolbar manager object 571 (E5ToolBarManager) 572 """ 573 self.initOnDemandPlugins() 574 for pluginObject in self.__onDemandInactivePlugins.values(): 575 with contextlib.suppress(AttributeError): 576 pluginObject.initToolbar(self.__ui, toolbarManager) 577 578 def activatePlugins(self): 579 """ 580 Public method to activate all plugins having the "autoactivate" 581 attribute set to True. 582 """ 583 savedInactiveList = Preferences.Prefs.settings.value( 584 self.__inactivePluginsKey) 585 inactiveList = self.__disabledPlugins[:] 586 if savedInactiveList is not None: 587 inactiveList += [p for p in savedInactiveList 588 if p not in self.__disabledPlugins] 589 if ( 590 self.__develPluginName is not None and 591 self.__develPluginName in inactiveList 592 ): 593 inactiveList.remove(self.__develPluginName) 594 names = sorted(self.__inactiveModules.keys()) 595 for name in names: 596 if name not in inactiveList: 597 self.activatePlugin(name) 598 self.allPlugginsActivated.emit() 599 600 def activatePlugin(self, name, onDemand=False): 601 """ 602 Public method to activate a plugin. 603 604 @param name name of the module to be activated 605 @param onDemand flag indicating activation of an 606 on demand plugin (boolean) 607 @return reference to the initialized plugin object 608 @exception PluginActivationError raised to indicate an issue during the 609 plug-in activation 610 """ 611 try: 612 try: 613 module = ( 614 self.__onDemandInactiveModules[name] 615 if onDemand else 616 self.__inactiveModules[name] 617 ) 618 except KeyError: 619 return None 620 621 if not self.__canActivatePlugin(module): 622 raise PluginActivationError(module.eric6PluginModuleName) 623 version = getattr(module, "version", "0.0.0") 624 className = getattr(module, "className", "") 625 pluginClass = getattr(module, className) 626 pluginObject = None 627 if onDemand and name in self.__onDemandInactivePlugins: 628 pluginObject = self.__onDemandInactivePlugins[name] 629 elif not onDemand and name in self.__inactivePlugins: 630 pluginObject = self.__inactivePlugins[name] 631 else: 632 pluginObject = pluginClass(self.__ui) 633 self.pluginAboutToBeActivated.emit(name, pluginObject) 634 try: 635 obj, ok = pluginObject.activate() 636 except TypeError: 637 module.error = self.tr( 638 "Incompatible plugin activation method.") 639 logging.debug( 640 "{0}: Incompatible plugin activation method." 641 .format(name) 642 ) 643 obj = None 644 ok = True 645 except Exception as err: 646 module.error = str(err) 647 logging.debug("{0}: {1}".format(name, str(err))) 648 obj = None 649 ok = False 650 if not ok: 651 return None 652 653 self.pluginActivated.emit(name, pluginObject) 654 pluginObject.eric6PluginModule = module 655 pluginObject.eric6PluginName = className 656 pluginObject.eric6PluginVersion = version 657 658 if onDemand: 659 self.__onDemandInactiveModules.pop(name) 660 with contextlib.suppress(KeyError): 661 self.__onDemandInactivePlugins.pop(name) 662 self.__onDemandActivePlugins[name] = pluginObject 663 self.__onDemandActiveModules[name] = module 664 else: 665 self.__inactiveModules.pop(name) 666 with contextlib.suppress(KeyError): 667 self.__inactivePlugins.pop(name) 668 self.__activePlugins[name] = pluginObject 669 self.__activeModules[name] = module 670 return obj 671 except PluginActivationError: 672 return None 673 674 def __canActivatePlugin(self, module): 675 """ 676 Private method to check, if a plugin can be activated. 677 678 @param module reference to the module to be activated 679 @return flag indicating, if the module satisfies all requirements 680 for being activated (boolean) 681 @exception PluginModuleFormatError raised to indicate an invalid 682 plug-in module format 683 @exception PluginClassFormatError raised to indicate an invalid 684 plug-in class format 685 """ 686 try: 687 if not hasattr(module, "version"): 688 raise PluginModuleFormatError( 689 module.eric6PluginModuleName, "version") 690 if not hasattr(module, "className"): 691 raise PluginModuleFormatError( 692 module.eric6PluginModuleName, "className") 693 className = getattr(module, "className", "") 694 if not className or not hasattr(module, className): 695 raise PluginModuleFormatError( 696 module.eric6PluginModuleName, className) 697 pluginClass = getattr(module, className) 698 if not hasattr(pluginClass, "__init__"): 699 raise PluginClassFormatError( 700 module.eric6PluginModuleName, 701 className, "__init__") 702 if not hasattr(pluginClass, "activate"): 703 raise PluginClassFormatError( 704 module.eric6PluginModuleName, 705 className, "activate") 706 if not hasattr(pluginClass, "deactivate"): 707 raise PluginClassFormatError( 708 module.eric6PluginModuleName, 709 className, "deactivate") 710 return True 711 except PluginModuleFormatError as e: 712 print(repr(e)) 713 return False 714 except PluginClassFormatError as e: 715 print(repr(e)) 716 return False 717 718 def deactivatePlugin(self, name, onDemand=False): 719 """ 720 Public method to deactivate a plugin. 721 722 @param name name of the module to be deactivated 723 @param onDemand flag indicating deactivation of an 724 on demand plugin (boolean) 725 """ 726 try: 727 module = ( 728 self.__onDemandActiveModules[name] 729 if onDemand else 730 self.__activeModules[name] 731 ) 732 except KeyError: 733 return 734 735 if self.__canDeactivatePlugin(module): 736 pluginObject = None 737 if onDemand and name in self.__onDemandActivePlugins: 738 pluginObject = self.__onDemandActivePlugins[name] 739 elif not onDemand and name in self.__activePlugins: 740 pluginObject = self.__activePlugins[name] 741 if pluginObject: 742 self.pluginAboutToBeDeactivated.emit(name, pluginObject) 743 pluginObject.deactivate() 744 self.pluginDeactivated.emit(name, pluginObject) 745 746 if onDemand: 747 self.__onDemandActiveModules.pop(name) 748 self.__onDemandActivePlugins.pop(name) 749 self.__onDemandInactivePlugins[name] = pluginObject 750 self.__onDemandInactiveModules[name] = module 751 else: 752 self.__activeModules.pop(name) 753 with contextlib.suppress(KeyError): 754 self.__activePlugins.pop(name) 755 self.__inactivePlugins[name] = pluginObject 756 self.__inactiveModules[name] = module 757 758 def __canDeactivatePlugin(self, module): 759 """ 760 Private method to check, if a plugin can be deactivated. 761 762 @param module reference to the module to be deactivated 763 @return flag indicating, if the module satisfies all requirements 764 for being deactivated (boolean) 765 """ 766 return getattr(module, "deactivateable", True) 767 768 def getPluginObject(self, type_, typename, maybeActive=False): 769 """ 770 Public method to activate an ondemand plugin given by type and 771 typename. 772 773 @param type_ type of the plugin to be activated (string) 774 @param typename name of the plugin within the type category (string) 775 @param maybeActive flag indicating, that the plugin may be active 776 already (boolean) 777 @return reference to the initialized plugin object 778 """ 779 for name, module in list(self.__onDemandInactiveModules.items()): 780 if ( 781 getattr(module, "pluginType", "") == type_ and 782 getattr(module, "pluginTypename", "") == typename 783 ): 784 return self.activatePlugin(name, onDemand=True) 785 786 if maybeActive: 787 for name, module in list(self.__onDemandActiveModules.items()): 788 if ( 789 getattr(module, "pluginType", "") == type_ and 790 getattr(module, "pluginTypename", "") == typename 791 ): 792 self.deactivatePlugin(name, onDemand=True) 793 return self.activatePlugin(name, onDemand=True) 794 795 return None 796 797 def getPluginInfos(self): 798 """ 799 Public method to get infos about all loaded plug-ins. 800 801 @return list of dictionaries with keys "module_name", "plugin_name", 802 "version", "auto_activate", "active", "short_desc", "error" 803 @rtype list of dict ("module_name": str, "plugin_name": str, 804 "version": str, "auto_activate": bool, "active": bool, 805 "short_desc": str, "error": bool) 806 """ 807 infos = [] 808 809 # 1. active, non-on-demand modules 810 for name in list(self.__activeModules.keys()): 811 info = self.__getShortInfo(self.__activeModules[name]) 812 info.update({ 813 "module_name": name, 814 "auto_activate": True, 815 "active": True, 816 }) 817 infos.append(info) 818 819 # 2. inactive, non-on-demand modules 820 for name in list(self.__inactiveModules.keys()): 821 info = self.__getShortInfo(self.__inactiveModules[name]) 822 info.update({ 823 "module_name": name, 824 "auto_activate": True, 825 "active": False, 826 }) 827 infos.append(info) 828 829 # 3. active, on-demand modules 830 for name in list(self.__onDemandActiveModules.keys()): 831 info = self.__getShortInfo(self.__onDemandActiveModules[name]) 832 info.update({ 833 "module_name": name, 834 "auto_activate": False, 835 "active": True, 836 }) 837 infos.append(info) 838 839 # 4. inactive, non-on-demand modules 840 for name in list(self.__onDemandInactiveModules.keys()): 841 info = self.__getShortInfo(self.__onDemandInactiveModules[name]) 842 info.update({ 843 "module_name": name, 844 "auto_activate": False, 845 "active": False, 846 }) 847 infos.append(info) 848 849 # 5. failed modules 850 for name in list(self.__failedModules.keys()): 851 info = self.__getShortInfo(self.__failedModules[name]) 852 info.update({ 853 "module_name": name, 854 "auto_activate": False, 855 "active": False, 856 }) 857 infos.append(info) 858 859 return infos 860 861 def __getShortInfo(self, module): 862 """ 863 Private method to extract the short info from a module. 864 865 @param module module to extract short info from 866 @return dictionay containing plug-in data 867 @rtype dict ("plugin_name": str, "version": str, "short_desc": str, 868 "error": bool) 869 """ 870 return { 871 "plugin_name": getattr(module, "name", ""), 872 "version": getattr(module, "version", ""), 873 "short_desc": getattr(module, "shortDescription", ""), 874 "error": bool(getattr(module, "error", "")), 875 } 876 877 def getPluginDetails(self, name): 878 """ 879 Public method to get detailed information about a plugin. 880 881 @param name name of the module to get detailed infos about (string) 882 @return details of the plugin as a dictionary 883 """ 884 details = {} 885 886 autoactivate = True 887 active = True 888 889 if name in self.__activeModules: 890 module = self.__activeModules[name] 891 elif name in self.__inactiveModules: 892 module = self.__inactiveModules[name] 893 active = False 894 elif name in self.__onDemandActiveModules: 895 module = self.__onDemandActiveModules[name] 896 autoactivate = False 897 elif name in self.__onDemandInactiveModules: 898 module = self.__onDemandInactiveModules[name] 899 autoactivate = False 900 active = False 901 elif name in self.__failedModules: 902 module = self.__failedModules[name] 903 autoactivate = False 904 active = False 905 elif "_" in name: 906 # try stripping of a postfix 907 return self.getPluginDetails(name.rsplit("_", 1)[0]) 908 else: 909 # should not happen 910 return None 911 912 details["moduleName"] = name 913 details["moduleFileName"] = getattr( 914 module, "eric6PluginModuleFilename", "") 915 details["pluginName"] = getattr(module, "name", "") 916 details["version"] = getattr(module, "version", "") 917 details["author"] = getattr(module, "author", "") 918 details["description"] = getattr(module, "longDescription", "") 919 details["autoactivate"] = autoactivate 920 details["active"] = active 921 details["error"] = getattr(module, "error", "") 922 923 return details 924 925 def doShutdown(self): 926 """ 927 Public method called to perform actions upon shutdown of the IDE. 928 """ 929 names = [] 930 for name in list(self.__inactiveModules.keys()): 931 names.append(name) 932 Preferences.Prefs.settings.setValue(self.__inactivePluginsKey, names) 933 934 self.shutdown.emit() 935 936 def getPluginDisplayStrings(self, type_): 937 """ 938 Public method to get the display strings of all plugins of a specific 939 type. 940 941 @param type_ type of the plugins (string) 942 @return dictionary with name as key and display string as value 943 (dictionary of string) 944 """ 945 pluginDict = {} 946 947 for module in ( 948 list(self.__onDemandActiveModules.values()) + 949 list(self.__onDemandInactiveModules.values()) 950 ): 951 if ( 952 getattr(module, "pluginType", "") == type_ and 953 getattr(module, "error", "") == "" 954 ): 955 plugin_name = getattr(module, "pluginTypename", "") 956 if plugin_name: 957 if hasattr(module, "displayString"): 958 try: 959 disp = module.displayString() 960 except TypeError: 961 disp = getattr(module, "displayString", "") 962 if disp != "": 963 pluginDict[plugin_name] = disp 964 else: 965 pluginDict[plugin_name] = plugin_name 966 967 return pluginDict 968 969 def getPluginPreviewPixmap(self, type_, name): 970 """ 971 Public method to get a preview pixmap of a plugin of a specific type. 972 973 @param type_ type of the plugin (string) 974 @param name name of the plugin type (string) 975 @return preview pixmap (QPixmap) 976 """ 977 for module in ( 978 list(self.__onDemandActiveModules.values()) + 979 list(self.__onDemandInactiveModules.values()) 980 ): 981 if ( 982 getattr(module, "pluginType", "") == type_ and 983 getattr(module, "pluginTypename", "") == name 984 ): 985 if hasattr(module, "previewPix"): 986 return module.previewPix() 987 else: 988 return QPixmap() 989 990 return QPixmap() 991 992 def getPluginApiFiles(self, language): 993 """ 994 Public method to get the list of API files installed by a plugin. 995 996 @param language language of the requested API files (string) 997 @return list of API filenames (list of string) 998 """ 999 apis = [] 1000 1001 for module in ( 1002 list(self.__activeModules.values()) + 1003 list(self.__onDemandActiveModules.values()) 1004 ): 1005 if hasattr(module, "apiFiles"): 1006 apis.extend(module.apiFiles(language)) 1007 1008 return apis 1009 1010 def getPluginQtHelpFiles(self): 1011 """ 1012 Public method to get the list of QtHelp documentation files provided 1013 by a plug-in. 1014 1015 @return dictionary with documentation type as key and list of files 1016 as value 1017 @rtype dict (key: str, value: list of str) 1018 """ 1019 helpFiles = {} 1020 for module in ( 1021 list(self.__activeModules.values()) + 1022 list(self.__onDemandActiveModules.values()) 1023 ): 1024 if hasattr(module, "helpFiles"): 1025 helpFiles.update(module.helpFiles()) 1026 1027 return helpFiles 1028 1029 def getPluginExeDisplayData(self): 1030 """ 1031 Public method to get data to display information about a plugins 1032 external tool. 1033 1034 @return list of dictionaries containing the data. Each dictionary must 1035 either contain data for the determination or the data to be 1036 displayed.<br /> 1037 A dictionary of the first form must have the following entries: 1038 <ul> 1039 <li>programEntry - indicator for this dictionary form 1040 (boolean), always True</li> 1041 <li>header - string to be diplayed as a header (string)</li> 1042 <li>exe - the executable (string)</li> 1043 <li>versionCommand - commandline parameter for the exe 1044 (string)</li> 1045 <li>versionStartsWith - indicator for the output line 1046 containing the version (string)</li> 1047 <li>versionPosition - number of element containing the 1048 version (integer)</li> 1049 <li>version - version to be used as default (string)</li> 1050 <li>versionCleanup - tuple of two integers giving string 1051 positions start and stop for the version string 1052 (tuple of integers)</li> 1053 </ul> 1054 A dictionary of the second form must have the following entries: 1055 <ul> 1056 <li>programEntry - indicator for this dictionary form 1057 (boolean), always False</li> 1058 <li>header - string to be diplayed as a header (string)</li> 1059 <li>text - entry text to be shown (string)</li> 1060 <li>version - version text to be shown (string)</li> 1061 </ul> 1062 """ 1063 infos = [] 1064 1065 for module in ( 1066 list(self.__activeModules.values()) + 1067 list(self.__inactiveModules.values()) 1068 ): 1069 if hasattr(module, "exeDisplayDataList"): 1070 infos.extend(module.exeDisplayDataList()) 1071 elif hasattr(module, "exeDisplayData"): 1072 infos.append(module.exeDisplayData()) 1073 for module in ( 1074 list(self.__onDemandActiveModules.values()) + 1075 list(self.__onDemandInactiveModules.values()) 1076 ): 1077 if hasattr(module, "exeDisplayDataList"): 1078 infos.extend(module.exeDisplayDataList()) 1079 elif hasattr(module, "exeDisplayData"): 1080 infos.append(module.exeDisplayData()) 1081 1082 return infos 1083 1084 def getPluginConfigData(self): 1085 """ 1086 Public method to get the config data of all active, non on-demand 1087 plugins used by the configuration dialog. 1088 1089 Plugins supporting this functionality must provide the plugin module 1090 function 'getConfigData' returning a dictionary with unique keys 1091 of lists with the following list contents: 1092 <dl> 1093 <dt>display string</dt> 1094 <dd>string shown in the selection area of the configuration page. 1095 This should be a localized string</dd> 1096 <dt>pixmap name</dt> 1097 <dd>filename of the pixmap to be shown next to the display 1098 string</dd> 1099 <dt>page creation function</dt> 1100 <dd>plugin module function to be called to create the configuration 1101 page. The page must be subclasses from 1102 Preferences.ConfigurationPages.ConfigurationPageBase and must 1103 implement a method called 'save' to save the settings. A parent 1104 entry will be created in the selection list, if this value is 1105 None.</dd> 1106 <dt>parent key</dt> 1107 <dd>dictionary key of the parent entry or None, if this defines a 1108 toplevel entry.</dd> 1109 <dt>reference to configuration page</dt> 1110 <dd>This will be used by the configuration dialog and must always 1111 be None</dd> 1112 </dl> 1113 1114 @return plug-in configuration data 1115 """ 1116 configData = {} 1117 for module in ( 1118 list(self.__activeModules.values()) + 1119 list(self.__onDemandActiveModules.values()) + 1120 list(self.__onDemandInactiveModules.values()) 1121 ): 1122 if hasattr(module, 'getConfigData'): 1123 configData.update(module.getConfigData()) 1124 return configData 1125 1126 def isPluginLoaded(self, pluginName): 1127 """ 1128 Public method to check, if a certain plugin is loaded. 1129 1130 @param pluginName name of the plugin to check for (string) 1131 @return flag indicating, if the plugin is loaded (boolean) 1132 """ 1133 return ( 1134 pluginName in self.__activeModules or 1135 pluginName in self.__inactiveModules or 1136 pluginName in self.__onDemandActiveModules or 1137 pluginName in self.__onDemandInactiveModules 1138 ) 1139 1140 def isPluginActive(self, pluginName): 1141 """ 1142 Public method to check, if a certain plugin is active. 1143 1144 @param pluginName name of the plugin to check for (string) 1145 @return flag indicating, if the plugin is active (boolean) 1146 """ 1147 return ( 1148 pluginName in self.__activeModules or 1149 pluginName in self.__onDemandActiveModules 1150 ) 1151 1152 ########################################################################### 1153 ## Specialized plug-in module handling methods below 1154 ########################################################################### 1155 1156 ########################################################################### 1157 ## VCS related methods below 1158 ########################################################################### 1159 1160 def getVcsSystemIndicators(self): 1161 """ 1162 Public method to get the Vcs System indicators. 1163 1164 Plugins supporting this functionality must support the module function 1165 getVcsSystemIndicator returning a dictionary with indicator as key and 1166 a tuple with the vcs name (string) and vcs display string (string). 1167 1168 @return dictionary with indicator as key and a list of tuples as 1169 values. Each tuple contains the vcs name (string) and vcs display 1170 string (string). 1171 """ 1172 vcsDict = {} 1173 1174 for module in ( 1175 list(self.__onDemandActiveModules.values()) + 1176 list(self.__onDemandInactiveModules.values()) 1177 ): 1178 if ( 1179 getattr(module, "pluginType", "") == "version_control" and 1180 hasattr(module, "getVcsSystemIndicator") 1181 ): 1182 res = module.getVcsSystemIndicator() 1183 for indicator, vcsData in list(res.items()): 1184 if indicator in vcsDict: 1185 vcsDict[indicator].append(vcsData) 1186 else: 1187 vcsDict[indicator] = [vcsData] 1188 1189 return vcsDict 1190 1191 def deactivateVcsPlugins(self): 1192 """ 1193 Public method to deactivated all activated VCS plugins. 1194 """ 1195 for name, module in list(self.__onDemandActiveModules.items()): 1196 if getattr(module, "pluginType", "") == "version_control": 1197 self.deactivatePlugin(name, True) 1198 1199 ######################################################################## 1200 ## Methods for the creation of the plug-ins download directory 1201 ######################################################################## 1202 1203 def __checkPluginsDownloadDirectory(self): 1204 """ 1205 Private slot to check for the existence of the plugins download 1206 directory. 1207 """ 1208 downloadDir = Preferences.getPluginManager("DownloadPath") 1209 if not downloadDir: 1210 downloadDir = self.__defaultDownloadDir 1211 1212 if not os.path.exists(downloadDir): 1213 try: 1214 os.mkdir(downloadDir, 0o755) 1215 except OSError: 1216 # try again with (possibly) new default 1217 downloadDir = self.__defaultDownloadDir 1218 if not os.path.exists(downloadDir): 1219 try: 1220 os.mkdir(downloadDir, 0o755) 1221 except OSError as err: 1222 E5MessageBox.critical( 1223 self.__ui, 1224 self.tr("Plugin Manager Error"), 1225 self.tr( 1226 """<p>The plugin download directory""" 1227 """ <b>{0}</b> could not be created. Please""" 1228 """ configure it via the configuration""" 1229 """ dialog.</p><p>Reason: {1}</p>""") 1230 .format(downloadDir, str(err))) 1231 downloadDir = "" 1232 1233 Preferences.setPluginManager("DownloadPath", downloadDir) 1234 1235 def preferencesChanged(self): 1236 """ 1237 Public slot to react to changes in configuration. 1238 """ 1239 self.__checkPluginsDownloadDirectory() 1240 1241 ######################################################################## 1242 ## Methods for automatic plug-in update check below 1243 ######################################################################## 1244 1245 def __onlineStateChanged(self, online): 1246 """ 1247 Private slot handling changes in online state. 1248 1249 @param online flag indicating the online state 1250 @type bool 1251 """ 1252 if online: 1253 self.checkPluginUpdatesAvailable() 1254 1255 def checkPluginUpdatesAvailable(self): 1256 """ 1257 Public method to check the availability of updates of plug-ins. 1258 """ 1259 period = Preferences.getPluginManager("UpdatesCheckInterval") 1260 if period == 0: 1261 return 1262 elif period in [1, 2, 3]: 1263 lastModified = QFileInfo(self.pluginRepositoryFile).lastModified() 1264 if lastModified.isValid() and lastModified.date().isValid(): 1265 lastModifiedDate = lastModified.date() 1266 now = QDate.currentDate() 1267 if ( 1268 (period == 1 and lastModifiedDate.day() == now.day()) or 1269 (period == 2 and lastModifiedDate.daysTo(now) < 7) or 1270 (period == 3 and (lastModifiedDate.daysTo(now) < 1271 lastModifiedDate.daysInMonth())) 1272 ): 1273 # daily, weekly, monthly 1274 return 1275 1276 self.__updateAvailable = False 1277 1278 request = QNetworkRequest( 1279 QUrl(Preferences.getUI("PluginRepositoryUrl6"))) 1280 request.setAttribute( 1281 QNetworkRequest.Attribute.CacheLoadControlAttribute, 1282 QNetworkRequest.CacheLoadControl.AlwaysNetwork) 1283 reply = self.__networkManager.get(request) 1284 reply.finished.connect( 1285 lambda: self.__downloadRepositoryFileDone(reply)) 1286 self.__replies.append(reply) 1287 1288 def __downloadRepositoryFileDone(self, reply): 1289 """ 1290 Private method called after the repository file was downloaded. 1291 1292 @param reply reference to the reply object of the download 1293 @type QNetworkReply 1294 """ 1295 if reply in self.__replies: 1296 self.__replies.remove(reply) 1297 1298 if reply.error() != QNetworkReply.NetworkError.NoError: 1299 E5MessageBox.warning( 1300 None, 1301 self.tr("Error downloading file"), 1302 self.tr( 1303 """<p>Could not download the requested file""" 1304 """ from {0}.</p><p>Error: {1}</p>""" 1305 ).format(Preferences.getUI("PluginRepositoryUrl6"), 1306 reply.errorString()) 1307 ) 1308 reply.deleteLater() 1309 return 1310 1311 ioDevice = QFile(self.pluginRepositoryFile + ".tmp") 1312 ioDevice.open(QIODevice.OpenModeFlag.WriteOnly) 1313 ioDevice.write(reply.readAll()) 1314 ioDevice.close() 1315 if QFile.exists(self.pluginRepositoryFile): 1316 QFile.remove(self.pluginRepositoryFile) 1317 ioDevice.rename(self.pluginRepositoryFile) 1318 reply.deleteLater() 1319 1320 if os.path.exists(self.pluginRepositoryFile): 1321 f = QFile(self.pluginRepositoryFile) 1322 if f.open(QIODevice.OpenModeFlag.ReadOnly): 1323 # save current URL 1324 url = Preferences.getUI("PluginRepositoryUrl6") 1325 1326 # read the repository file 1327 from E5XML.PluginRepositoryReader import PluginRepositoryReader 1328 reader = PluginRepositoryReader(f, self.checkPluginEntry) 1329 reader.readXML() 1330 if url != Preferences.getUI("PluginRepositoryUrl6"): 1331 # redo if it is a redirect 1332 self.checkPluginUpdatesAvailable() 1333 return 1334 1335 if self.__updateAvailable: 1336 res = E5MessageBox.information( 1337 None, 1338 self.tr("New plugin versions available"), 1339 self.tr("<p>There are new plug-ins or plug-in" 1340 " updates available. Use the plug-in" 1341 " repository dialog to get them.</p>"), 1342 E5MessageBox.StandardButtons( 1343 E5MessageBox.Ignore | 1344 E5MessageBox.Open), 1345 E5MessageBox.Open) 1346 if res == E5MessageBox.Open: 1347 self.__ui.showPluginsAvailable() 1348 1349 def checkPluginEntry(self, name, short, description, url, author, version, 1350 filename, status): 1351 """ 1352 Public method to check a plug-in's data for an update. 1353 1354 @param name data for the name field (string) 1355 @param short data for the short field (string) 1356 @param description data for the description field (list of strings) 1357 @param url data for the url field (string) 1358 @param author data for the author field (string) 1359 @param version data for the version field (string) 1360 @param filename data for the filename field (string) 1361 @param status status of the plugin (string [stable, unstable, unknown]) 1362 """ 1363 # ignore hidden plug-ins 1364 pluginName = os.path.splitext(url.rsplit("/", 1)[1])[0] 1365 if pluginName in Preferences.getPluginManager("HiddenPlugins"): 1366 return 1367 1368 archive = os.path.join(Preferences.getPluginManager("DownloadPath"), 1369 filename) 1370 1371 # Check against installed/loaded plug-ins 1372 pluginDetails = self.getPluginDetails(pluginName) 1373 if pluginDetails is None: 1374 if not Preferences.getPluginManager("CheckInstalledOnly"): 1375 self.__updateAvailable = True 1376 return 1377 1378 versionTuple = Globals.versionToTuple(version)[:3] 1379 pluginVersionTuple = Globals.versionToTuple( 1380 pluginDetails["version"])[:3] 1381 1382 if pluginVersionTuple < versionTuple: 1383 self.__updateAvailable = True 1384 return 1385 1386 if not Preferences.getPluginManager("CheckInstalledOnly"): 1387 # Check against downloaded plugin archives 1388 # 1. Check, if the archive file exists 1389 if not os.path.exists(archive): 1390 if pluginDetails["moduleName"] != pluginName: 1391 self.__updateAvailable = True 1392 return 1393 1394 # 2. Check, if the archive is a valid zip file 1395 if not zipfile.is_zipfile(archive): 1396 self.__updateAvailable = True 1397 return 1398 1399 # 3. Check the version of the archive file 1400 zipFile = zipfile.ZipFile(archive, "r") 1401 try: 1402 aversion = zipFile.read("VERSION").decode("utf-8") 1403 except KeyError: 1404 aversion = "0.0.0" 1405 zipFile.close() 1406 1407 aversionTuple = Globals.versionToTuple(aversion)[:3] 1408 if aversionTuple != versionTuple: 1409 self.__updateAvailable = True 1410 1411 def __sslErrors(self, reply, errors): 1412 """ 1413 Private slot to handle SSL errors. 1414 1415 @param reply reference to the reply object (QNetworkReply) 1416 @param errors list of SSL errors (list of QSslError) 1417 """ 1418 ignored = self.__sslErrorHandler.sslErrorsReply(reply, errors)[0] 1419 if ignored == E5SslErrorState.NOT_IGNORED: 1420 self.__downloadCancelled = True 1421 1422 ######################################################################## 1423 ## Methods to clear private data of plug-ins below 1424 ######################################################################## 1425 1426 def clearPluginsPrivateData(self, type_): 1427 """ 1428 Public method to clear the private data of plug-ins of a specified 1429 type. 1430 1431 Plugins supporting this functionality must support the module function 1432 clearPrivateData() and have the module level attribute pluginType. 1433 1434 @param type_ type of the plugin to clear private data for (string) 1435 """ 1436 for module in ( 1437 list(self.__onDemandActiveModules.values()) + 1438 list(self.__onDemandInactiveModules.values()) + 1439 list(self.__activeModules.values()) + 1440 list(self.__inactiveModules.values()) 1441 ): 1442 if ( 1443 getattr(module, "pluginType", "") == type_ and 1444 hasattr(module, "clearPrivateData") 1445 ): 1446 module.clearPrivateData() 1447 1448 ######################################################################## 1449 ## Methods to install a plug-in module dependency via pip 1450 ######################################################################## 1451 1452 def pipInstall(self, packages): 1453 """ 1454 Public method to install the given package via pip. 1455 1456 @param packages list of packages to install 1457 @type list of str 1458 """ 1459 try: 1460 pip = e5App().getObject("Pip") 1461 except KeyError: 1462 # Installation is performed via the plug-in installation script. 1463 from PipInterface.Pip import Pip 1464 pip = Pip(self) 1465 pip.installPackages(packages, interpreter=sys.executable) 1466 1467# 1468# eflag: noqa = M801 1469