Thu, 19 Nov 2020 18:34:05 +0100
Continued implementing pybabel translations support.
# -*- coding: utf-8 -*- # Copyright (c) 2020 Detlev Offenbach <detlev@die-offenbachs.de> # """ Module implementing the Flask project support. """ import os from PyQt5.QtCore import ( pyqtSlot, QObject, QProcess, QProcessEnvironment, QTimer ) from PyQt5.QtWidgets import QMenu, QDialog from E5Gui import E5MessageBox from E5Gui.E5Action import E5Action from E5Gui.E5Application import e5App from Globals import isWindowsPlatform import UI.PixmapCache import Utilities class Project(QObject): """ Class implementing the Flask project support. """ def __init__(self, plugin, iconSuffix, parent=None): """ Constructor @param plugin reference to the plugin object @type ProjectFlaskPlugin @param iconSuffix suffix for the icons @type str @param parent parent @type QObject """ super(Project, self).__init__(parent) self.__plugin = plugin self.__iconSuffix = iconSuffix self.__ui = parent self.__e5project = e5App().getObject("Project") self.__virtualEnvManager = e5App().getObject("VirtualEnvManager") self.__menus = {} # dictionary with references to menus self.__hooksInstalled = False self.__serverDialog = None self.__routesDialog = None self.__shellProcess = None self.__projectData = { "flask": {}, "pybabel": {}, } self.__flaskVersions = { "python": "", "flask": "", "werkzeug": "", } def initActions(self): """ Public method to define the Flask actions. """ self.actions = [] ############################## ## run actions below ## ############################## self.runServerAct = E5Action( self.tr('Run Server'), self.tr('Run &Server'), 0, 0, self, 'flask_run_server') self.runServerAct.setStatusTip(self.tr( 'Starts the Flask Web server')) self.runServerAct.setWhatsThis(self.tr( """<b>Run Server</b>""" """<p>Starts the Flask Web server.</p>""" )) self.runServerAct.triggered.connect(self.__runServer) self.actions.append(self.runServerAct) self.runDevServerAct = E5Action( self.tr('Run Development Server'), self.tr('Run &Development Server'), 0, 0, self, 'flask_run_dev_server') self.runDevServerAct.setStatusTip(self.tr( 'Starts the Flask Web server in development mode')) self.runDevServerAct.setWhatsThis(self.tr( """<b>Run Development Server</b>""" """<p>Starts the Flask Web server in development mode.</p>""" )) self.runDevServerAct.triggered.connect(self.__runDevelopmentServer) self.actions.append(self.runDevServerAct) self.askForServerOptionsAct = E5Action( self.tr('Ask for Server Start Options'), self.tr('Ask for Server Start Options'), 0, 0, self, 'flask_ask_server_options') self.askForServerOptionsAct.setStatusTip(self.tr( 'Ask for server start options')) self.askForServerOptionsAct.setWhatsThis(self.tr( """<b>Ask for Server Start Options</b>""" """<p>Asks for server start options before the Flask Web server""" """ is started. If this is unchecked, the server is started with""" """ default parameters.</p>""" )) self.askForServerOptionsAct.setCheckable(True) self.actions.append(self.askForServerOptionsAct) ############################### ## shell action below ## ############################### self.runPythonShellAct = E5Action( self.tr('Start Flask Python Console'), self.tr('Start Flask &Python Console'), 0, 0, self, 'flask_python_console') self.runPythonShellAct.setStatusTip(self.tr( 'Starts an interactive Python interpreter')) self.runPythonShellAct.setWhatsThis(self.tr( """<b>Start Flask Python Console</b>""" """<p>Starts an interactive Python interpreter.</p>""" )) self.runPythonShellAct.triggered.connect(self.__runPythonShell) self.actions.append(self.runPythonShellAct) ################################ ## routes action below ## ################################ self.showRoutesAct = E5Action( self.tr('Show Routes'), self.tr('Show &Routes'), 0, 0, self, 'flask_show_routes') self.showRoutesAct.setStatusTip(self.tr( 'Shows a dialog with the routes of the flask app')) self.showRoutesAct.setWhatsThis(self.tr( """<b>Show Routes</b>""" """<p>Shows a dialog with the routes of the flask app.</p>""" )) self.showRoutesAct.triggered.connect(self.__showRoutes) self.actions.append(self.showRoutesAct) ################################## ## database action below ## ################################## self.initDatabaseAct = E5Action( self.tr('Initialize Database'), self.tr('&Initialize Database'), 0, 0, self, 'flask_init_database') self.initDatabaseAct.setStatusTip(self.tr( 'Shows a dialog with the result of the database creation')) self.initDatabaseAct.setWhatsThis(self.tr( """<b>Initialize Database</b>""" """<p>Shows a dialog with the result of the database""" """ creation.</p>""" )) self.initDatabaseAct.triggered.connect(self.__initDatabase) self.actions.append(self.initDatabaseAct) ################################## ## database action below ## ################################## self.pybabelConfigAct = E5Action( self.tr('Configure PyBabel'), self.tr('Configure Py&Babel'), 0, 0, self, 'flask_config_pybabel') self.pybabelConfigAct.setStatusTip(self.tr( 'Shows a dialog to edit the configuration for pybabel')) self.pybabelConfigAct.setWhatsThis(self.tr( """<b>Configure PyBabel</b>""" """<p>Shows a dialog to edit the configuration for pybabel.</p>""" )) self.pybabelConfigAct.triggered.connect(self.__configurePybabel) self.actions.append(self.pybabelConfigAct) ################################## ## documentation action below ## ################################## self.documentationAct = E5Action( self.tr('Documentation'), self.tr('D&ocumentation'), 0, 0, self, 'flask_documentation') self.documentationAct.setStatusTip(self.tr( 'Shows the help viewer with the Flask documentation')) self.documentationAct.setWhatsThis(self.tr( """<b>Documentation</b>""" """<p>Shows the help viewer with the Flask documentation.</p>""" )) self.documentationAct.triggered.connect(self.__showDocumentation) self.actions.append(self.documentationAct) ############################## ## about action below ## ############################## self.aboutFlaskAct = E5Action( self.tr('About Flask'), self.tr('About &Flask'), 0, 0, self, 'flask_about') self.aboutFlaskAct.setStatusTip(self.tr( 'Shows some information about Flask')) self.aboutFlaskAct.setWhatsThis(self.tr( """<b>About Flask</b>""" """<p>Shows some information about Flask.</p>""" )) self.aboutFlaskAct.triggered.connect(self.__flaskInfo) self.actions.append(self.aboutFlaskAct) def initMenu(self): """ Public method to initialize the Flask menu. @return the menu generated @rtype QMenu """ self.__menus = {} # clear menus references menu = QMenu(self.tr('&Flask'), self.__ui) menu.setTearOffEnabled(True) menu.addSection("flask run") menu.addAction(self.runServerAct) menu.addAction(self.runDevServerAct) menu.addAction(self.askForServerOptionsAct) menu.addSection("flask shell") menu.addAction(self.runPythonShellAct) menu.addSection("flask routes") menu.addAction(self.showRoutesAct) menu.addSection("flask init-db") menu.addAction(self.initDatabaseAct) menu.addSection(self.tr("Translations")) menu.addAction(self.pybabelConfigAct) menu.addSection(self.tr("Various")) menu.addAction(self.documentationAct) menu.addSeparator() menu.addAction(self.aboutFlaskAct) self.__menus["main"] = menu return menu def getMenu(self, name): """ Public method to get a reference to the requested menu. @param name name of the menu @type str @return reference to the menu or None, if no menu with the given name exists @rtype QMenu or None """ if name in self.__menus: return self.__menus[name] else: return None def getMenuNames(self): """ Public method to get the names of all menus. @return menu names @rtype list of str """ return list(self.__menus.keys()) def registerOpenHook(self): """ Public method to register the open hook to open a translations file in a translations editor. """ if self.__hooksInstalled: editor = self.__plugin.getPreferences("TranslationsEditor") if editor: self.__translationsBrowser.addHookMethodAndMenuEntry( "open", self.openPOEditor, self.tr("Open with {0}").format( os.path.basename(editor))) else: self.__translationsBrowser.removeHookMethod("open") def projectOpenedHooks(self): """ Public method to add our hook methods. """ if self.__e5project.getProjectType() == "Flask": ## self.__formsBrowser = ( ## e5App().getObject("ProjectBrowser") ## .getProjectBrowser("forms")) ## self.__formsBrowser.addHookMethodAndMenuEntry( ## "newForm", self.newForm, self.tr("New template...")) ## if self.flaskBabelAvailable(): self.__e5project.projectLanguageAddedByCode.connect( self.__projectLanguageAdded) self.__translationsBrowser = ( e5App().getObject("ProjectBrowser") .getProjectBrowser("translations")) self.__translationsBrowser.addHookMethodAndMenuEntry( "extractMessages", self.extractMessages, self.tr("Extract Messages")) self.__translationsBrowser.addHookMethodAndMenuEntry( "releaseAll", self.compileCatalogs, self.tr("Compile All Catalogs")) self.__translationsBrowser.addHookMethodAndMenuEntry( "releaseSelected", self.compileSelectedCatalogs, self.tr("Compile Selected Catalogs")) self.__translationsBrowser.addHookMethodAndMenuEntry( "generateAll", self.updateCatalogs, self.tr("Update All Catalogs")) self.__translationsBrowser.addHookMethodAndMenuEntry( "generateSelected", self.updateSelectedCatalogs, self.tr("Update Selected Catalogs")) self.__hooksInstalled = True self.registerOpenHook() def projectClosedHooks(self): """ Public method to remove our hook methods. """ if self.__hooksInstalled: ## self.__formsBrowser.removeHookMethod("newForm") ## self.__formsBrowser = None ## self.__e5project.projectLanguageAddedByCode.disconnect( self.__projectLanguageAdded) self.__translationsBrowser.removeHookMethod("extractMessages") self.__translationsBrowser.removeHookMethod("releaseAll") self.__translationsBrowser.removeHookMethod("releaseSelected") self.__translationsBrowser.removeHookMethod("generateAll") self.__translationsBrowser.removeHookMethod("generateSelected") self.__translationsBrowser.removeHookMethod("open") self.__translationsBrowser = None self.__hooksInstalled = False ################################################################## ## slots below implement general functionality ################################################################## def projectClosed(self): """ Public method to handle the closing of a project. """ for dlg in (self.__serverDialog, self.__routesDialog): if dlg is not None: dlg.close() def supportedPythonVariants(self): """ Public method to get the supported Python variants. @return list of supported Python variants @rtype list of str """ variants = [] virtEnv = self.__getVirtualEnvironment() if virtEnv: fullCmd = self.getFlaskCommand() if fullCmd: variants.append("Python3") else: fullCmd = self.getFlaskCommand() if isWindowsPlatform(): if fullCmd: variants.append("Python3") else: fullCmds = Utilities.getExecutablePaths("flask") for fullCmd in fullCmds: try: with open(fullCmd, 'r', encoding='utf-8') as f: l0 = f.readline() except (IOError, OSError): l0 = "" if self.__isSuitableForVariant("Python3", l0): variants.append("Python3") break return variants def __isSuitableForVariant(self, variant, line0): """ Private method to test, if a detected command file is suitable for the given Python variant. @param variant Python variant to test for @type str @param line0 first line of the executable @type str @return flag indicating a suitable file was found @rtype bool """ l0 = line0.lower() ok = (variant.lower() in l0 or "{0}.".format(variant[-1]) in l0) ok |= "pypy3" in l0 return ok def __getVirtualEnvironment(self): """ Private method to get the path of the virtual environment. @return path of the virtual environment @rtype str """ language = self.__e5project.getProjectLanguage() if language == "Python3": venvName = self.__plugin.getPreferences( "VirtualEnvironmentNamePy3") else: venvName = "" if venvName: virtEnv = self.__virtualEnvManager.getVirtualenvDirectory( venvName) else: virtEnv = "" if virtEnv and not os.path.exists(virtEnv): virtEnv = "" return virtEnv # __IGNORE_WARNING_M834__ def getFlaskCommand(self): """ Public method to build the Flask command. @return full flask command @rtype str """ return self.__getFullCommand("flask") def getBabelCommand(self): """ Public method to build the Babel command. @return full pybabel command @rtype str """ return self.__getFullCommand("pybabel") def __getFullCommand(self, command): """ Private method to get the full command for a given command name. @param command command name @type str @return full command @rtype str """ virtualEnv = self.__getVirtualEnvironment() if isWindowsPlatform(): fullCmds = [ os.path.join(virtualEnv, "Scripts", command + '.exe'), os.path.join(virtualEnv, "bin", command + '.exe'), command # fall back to just cmd ] else: fullCmds = [ os.path.join(virtualEnv, "bin", command), os.path.join(virtualEnv, "local", "bin", command), Utilities.getExecutablePath(command), command # fall back to just cmd ] for command in fullCmds: if os.path.exists(command): break return command @pyqtSlot() def __flaskInfo(self): """ Private slot to show some info about Flask. """ versions = self.getFlaskVersionStrings() url = "https://palletsprojects.com/p/flask/" msgBox = E5MessageBox.E5MessageBox( E5MessageBox.Question, self.tr("About Flask"), self.tr( "<p>Flask is a lightweight WSGI web application framework." " It is designed to make getting started quick and easy," " with the ability to scale up to complex applications.</p>" "<p><table>" "<tr><td>Flask Version:</td><td>{0}</td></tr>" "<tr><td>Werkzeug Version:</td><td>{1}</td></tr>" "<tr><td>Python Version:</td><td>{2}</td></tr>" "<tr><td>Flask URL:</td><td><a href=\"{3}\">" "The Pallets Projects - Flask</a></td></tr>" "</table></p>" ).format(versions["flask"], versions["werkzeug"], versions["python"], url), modal=True, buttons=E5MessageBox.Ok) msgBox.setIconPixmap(UI.PixmapCache.getPixmap( os.path.join("ProjectFlask", "icons", "flask64-{0}".format(self.__iconSuffix)))) msgBox.exec() def getFlaskVersionStrings(self): """ Public method to get the Flask, Werkzeug and Python versions as a string. @return dictionary containing the Flask, Werkzeug and Python versions @rtype dict """ if not self.__flaskVersions["flask"]: cmd = self.getFlaskCommand() proc = QProcess() proc.start(cmd, ["--version"]) if proc.waitForFinished(10000): output = str(proc.readAllStandardOutput(), "utf-8") for line in output.lower().splitlines(): key, version = line.strip().split(None, 1) self.__flaskVersions[key] = version return self.__flaskVersions def prepareRuntimeEnvironment(self, development=False): """ Public method to prepare a QProcessEnvironment object and determine the appropriate working directory. @param development flag indicating development mode @type bool @return tuple containing the working directory and a prepared environment object to be used with QProcess @rtype tuple of (str, QProcessEnvironment) """ workdir, app = self.getApplication() env = QProcessEnvironment.systemEnvironment() env.insert("FLASK_APP", app) if development: env.insert("FLASK_ENV", "development") return workdir, env def getApplication(self): """ Public method to determine the application name and the respective working directory. @return tuple containing the working directory and the application name @rtype tuple of (str, str) """ mainScript = self.__e5project.getMainScript(normalized=True) if not mainScript: E5MessageBox.critical( self.__ui, self.tr("Prepare Environment"), self.tr("""The project has no configured main script""" """ (= Flask application). Aborting...""")) return "", None scriptPath, scriptName = os.path.split(mainScript) if scriptName == "__init__.py": workdir, app = os.path.split(scriptPath) else: workdir, app = scriptPath, scriptName return workdir, app def getData(self, category, key): """ Public method to get data stored in the project store. @param category data category @type str @param key data key @type str @return referenced data @rtype any """ if category not in self.__projectData: self.__projectData[category] = {} if not self.__projectData[category]: data = self.__e5project.getData( "PROJECTTYPESPECIFICDATA", category) if data is not None: self.__projectData[category] = data data = self.__projectData[category] if not key: # return complete category dictionary return data elif key in data: # return individual entry return data[key] else: # failure return None def setData(self, category, key, value): """ Public method to store data in the project store. @param category data category @type str @param key data key @type str @param value data to be stored @type any (serializable type) """ if category not in self.__projectData: self.__projectData[category] = {} if not self.__projectData[category]: data = self.__e5project.getData( "PROJECTTYPESPECIFICDATA", category) if data is not None: self.__projectData[category] = data if not key: # update the complete category self.__projectData[category] = value else: # update individual entry self.__projectData[category][key] = value self.__e5project.setData( "PROJECTTYPESPECIFICDATA", category, self.__projectData[category]) ################################################################## ## slots below implement documentation functions ################################################################## def __showDocumentation(self): """ Private slot to show the helpviewer with the Flask documentation. """ page = self.__plugin.getPreferences("FlaskDocUrl") self.__ui.launchHelpViewer(page) ################################################################## ## slots below implement run functions for the server ################################################################## @pyqtSlot() def __runServer(self, development=False): """ Private slot to start the Flask Web server. @param development flag indicating development mode @type bool """ from .RunServerDialog import RunServerDialog if self.__serverDialog is not None: self.__serverDialog.close() askForOptions = self.askForServerOptionsAct.isChecked() dlg = RunServerDialog(self.__plugin, self) if dlg.startServer(development=development, askForOptions=askForOptions): dlg.show() self.__serverDialog = dlg @pyqtSlot() def __runDevelopmentServer(self): """ Private slot to start the Flask Web server in development mode. """ self.__runServer(development=True) ################################################################## ## slots below implement functions for the flask console ################################################################## @pyqtSlot() def __runPythonShell(self): """ Private slot to start a Python console in the app context. """ workdir, env = self.prepareRuntimeEnvironment() if env is not None: command = self.getFlaskCommand() consoleCmd = self.__plugin.getPreferences("ConsoleCommand") if consoleCmd: self.__terminatePythonShell() args = Utilities.parseOptionString(consoleCmd) args[0] = Utilities.getExecutablePath(args[0]) args += [command, "shell"] self.__shellProcess = QProcess() self.__shellProcess.setProcessEnvironment(env) self.__shellProcess.setWorkingDirectory(workdir) self.__shellProcess.finished.connect( self.__shellProcessFinished) self.__shellProcess.start(args[0], args[1:]) self.__shellProcess.waitForStarted(10000) @pyqtSlot() def __shellProcessFinished(self): """ Private slot connected to the finished signal. """ self.__shellProcess = None def __terminatePythonShell(self): """ Private method to terminate the current Python console. """ if ( self.__shellProcess is not None and self.__shellProcess.state() != QProcess.NotRunning ): self.__shellProcess.terminate() QTimer.singleShot(2000, self.__shellProcess.kill) self.__shellProcess.waitForFinished(3000) ################################################################## ## slots below implement various debugging functions ################################################################## @pyqtSlot() def __showRoutes(self): """ Private slot showing all URL dispatch routes. """ from .RoutesDialog import RoutesDialog if self.__routesDialog is not None: self.__routesDialog.close() dlg = RoutesDialog(self) if dlg.showRoutes(): dlg.show() self.__routesDialog = dlg @pyqtSlot() def __initDatabase(self): """ Private slot showing the result of the database creation. """ from .FlaskCommandDialog import FlaskCommandDialog dlg = FlaskCommandDialog(self) if dlg.startCommand("init-db"): dlg.exec() ################################################################## ## slots and methods below implement i18n and l10n support ################################################################## def flaskBabelAvailable(self): """ Public method to check, if the 'flask-babel' package is available. @return flag indicating the availability of 'flask-babel' @rtype bool """ venvName = self.__plugin.getPreferences("VirtualEnvironmentNamePy3") interpreter = self.__virtualEnvManager.getVirtualenvInterpreter( venvName) if interpreter and Utilities.isinpath(interpreter): detector = os.path.join( os.path.dirname(__file__), "FlaskBabelDetector.py") proc = QProcess() proc.setProcessChannelMode(QProcess.MergedChannels) proc.start(interpreter, [detector]) finished = proc.waitForFinished(30000) if finished and proc.exitCode() == 0: return True return False @pyqtSlot() def __configurePybabel(self): """ Private slot to show a dialog to edit the pybabel configuration. """ # TODO: implement this from .PyBabelConfigDialog import PyBabelConfigDialog config = self.getData("pybabel", "") dlg = PyBabelConfigDialog(config) if dlg.exec() == QDialog.Accepted: config = dlg.getConfiguration() self.setData("pybabel", "", config) cfgFileName = self.__e5project.getAbsoluteUniversalPath( config["configFile"]) if not os.path.exists(cfgFileName): self.__createBabelCfg(cfgFileName) def __ensurePybabelConfigured(self): """ Private method to ensure, that PyBabel has been configured. @return flag indicating successful configuration @rtype bool """ config = self.getData("pybabel", "") if not config: self.__configurePybabel() return True configFileName = self.getData("pybabel", "configFile") if configFileName: cfgFileName = self.__e5project.getAbsoluteUniversalPath( configFileName) if os.path.exists(cfgFileName): return True else: return self.__createBabelCfg(cfgFileName) return False def __createBabelCfg(self, configFile): """ Private method to create a template pybabel configuration file. @return flag indicating successful configuration file creation @rtype bool """ _, app = self.getApplication() if app.endswith(".py"): template = ( "[python: {0}]\n" "[jinja2: templates/**.html]\n" "extensions=jinja2.ext.autoescape,jinja2.ext.with_\n" ) else: template = ( "[python: {0}/**.py]\n" "[jinja2: {0}/templates/**.html]\n" "extensions=jinja2.ext.autoescape,jinja2.ext.with_\n" ) try: with open(configFile, "w") as f: f.write(template.format(app)) self.__e5project.appendFile(configFile) E5MessageBox.information( None, self.tr("Generate PyBabel Configuration File"), self.tr("""The PyBabel configuration file was created.""" """ Please edit it to adjust the entries as""" """ required.""") ) return True except EnvironmentError as err: E5MessageBox.warning( None, self.tr("Generate PyBabel Configuration File"), self.tr("""<p>The PyBabel Configuration File could not be""" """ generated.</p><p>Reason: {0}</p>""") .format(str(err)) ) return False def __projectLanguageAdded(self, code): # TODO: implement this with pybabel ... pass def openPOEditor(self): # TODO: implement this with pybabel ... pass def extractMessages(self): # TODO: implement this with pybabel ... pass def compileCatalogs(self): # TODO: implement this with pybabel ... pass def compileSelectedCatalogs(self): # TODO: implement this with pybabel ... pass def updateCatalogs(self): # TODO: implement this with pybabel ... pass def updateSelectedCatalogs(self): # TODO: implement this with pybabel ... pass