Mon, 28 Oct 2024 17:11:28 +0100
- changed to the new style header
- ensured proper parent relationship of modal dialogs
- included compiled form files
# -*- coding: utf-8 -*- # Copyright (c) 2020 - 2024 Detlev Offenbach <detlev@die-offenbachs.de> # """ Module implementing the Flask project support. """ import os from PyQt6.QtCore import ( QFileInfo, QObject, QProcess, QProcessEnvironment, QTimer, pyqtSlot, ) from PyQt6.QtWidgets import QDialog, QMenu from eric7 import Utilities from eric7.EricGui.EricAction import EricAction from eric7.EricWidgets import EricFileDialog, EricMessageBox from eric7.EricWidgets.EricApplication import ericApp try: from eric7.EricGui import EricPixmapCache except ImportError: from UI import PixmapCache as EricPixmapCache try: from eric7.SystemUtilities.FileSystemUtilities import ( getExecutablePath, getExecutablePaths, ) except ImportError: # imports for eric < 23.1 from eric7.Utilities import getExecutablePath, getExecutablePaths try: from eric7.SystemUtilities.OSUtilities import isWindowsPlatform except ImportError: # imports for eric < 23.1 from eric7.Globals import isWindowsPlatform from .FlaskBabelExtension.PyBabelProjectExtension import PyBabelProject from .FlaskMigrateExtension.MigrateProjectExtension import MigrateProject 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().__init__(parent) self.__plugin = plugin self.__iconSuffix = iconSuffix self.__ui = parent self.__ericProject = ericApp().getObject("Project") self.__virtualEnvManager = ericApp().getObject("VirtualEnvManager") self.__menus = {} # dictionary with references to menus self.__formsBrowser = None self.__hooksInstalled = False self.__serverDialog = None self.__routesDialog = None self.__shellProcess = None self.__projectData = { "flask": {}, "flask-babel": {}, "flask-migrate": {}, } self.__flaskVersions = { "python": "", "flask": "", "werkzeug": "", } self.__capabilities = {} self.__pybabelProject = PyBabelProject(self.__plugin, self, self.__ui) self.__migrateProject = MigrateProject(self.__plugin, self, self.__ui) def initActions(self): """ Public method to define the Flask actions. """ self.actions = [] ############################## ## run actions below ## ############################## self.runServerAct = EricAction( 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 = EricAction( 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 = EricAction( 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 = EricAction( 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 = EricAction( 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) ################################## ## documentation action below ## ################################## self.documentationAct = EricAction( 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 = EricAction( 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) self.__pybabelProject.initActions() self.__migrateProject.initActions() ###################################### ## configuration action below ## ###################################### self.flaskConfigAct = EricAction( self.tr("Configure Flask for Project"), self.tr("Configure Flask for &Project"), 0, 0, self, "flask_config_for_project", ) self.flaskConfigAct.setStatusTip( self.tr("Shows a dialog to edit the project specific flask configuration") ) self.flaskConfigAct.setWhatsThis( self.tr( """<b>Configure Flask for Project</b>""" """<p>Shows a dialog to edit the project specific flask""" """ configuration.</p>""" ) ) self.flaskConfigAct.triggered.connect(self.__configureFlaskForProject) self.actions.append(self.flaskConfigAct) def initMenu(self): """ Public method to initialize the Flask menu. @return the menu generated @rtype QMenu """ self.__menus = {} # clear menus references self.__menus["flask-babel"] = self.__pybabelProject.initMenu() self.__menus["flask-migrate"] = self.__migrateProject.initMenu() menu = QMenu(self.tr("&Flask"), self.__ui) menu.setTearOffEnabled(True) menu.addAction(self.flaskConfigAct) menu.addSeparator() menu.addAction(self.runServerAct) menu.addAction(self.runDevServerAct) menu.addAction(self.askForServerOptionsAct) menu.addSeparator() menu.addAction(self.runPythonShellAct) menu.addSeparator() menu.addAction(self.showRoutesAct) menu.addSeparator() menu.addMenu(self.__menus["flask-migrate"]) menu.addSeparator() menu.addMenu(self.__menus["flask-babel"]) menu.addSeparator() 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 projectOpenedHooks(self): """ Public method to add our hook methods. """ if self.__ericProject.getProjectType() == "Flask": self.__formsBrowser = ( ericApp().getObject("ProjectBrowser").getProjectBrowser("forms") ) self.__formsBrowser.addHookMethodAndMenuEntry( "newForm", self.newForm, self.tr("New template...") ) self.__determineCapabilities() self.__setDebugEnvironment() self.__pybabelProject.projectOpenedHooks() self.__hooksInstalled = True def projectClosedHooks(self): """ Public method to remove our hook methods. """ self.__pybabelProject.projectClosedHooks() if self.__hooksInstalled: self.__formsBrowser.removeHookMethod("newForm") self.__formsBrowser = None self.__hooksInstalled = False def newForm(self, dirPath): """ Public method to create a new form. @param dirPath full directory path for the new form file @type str """ from .FormSelectionDialog import FormSelectionDialog dlg = FormSelectionDialog(parent=self.__ui) if dlg.exec() == QDialog.DialogCode.Accepted: template = dlg.getTemplateText() fileFilters = self.tr( "HTML Files (*.html);;HTML Files (*.htm);;All Files (*)" ) fname, selectedFilter = EricFileDialog.getSaveFileNameAndFilter( self.__ui, self.tr("New Form"), dirPath, fileFilters, None, EricFileDialog.Options(EricFileDialog.DontConfirmOverwrite), ) if fname: ext = QFileInfo(fname).suffix() if not ext: ex = selectedFilter.split("(*")[1].split(")")[0] if ex: fname += ex if os.path.exists(fname): res = EricMessageBox.yesNo( self.__ui, self.tr("New Form"), self.tr("""The file already exists! Overwrite it?"""), icon=EricMessageBox.Warning, ) if not res: # user selected to not overwrite return try: with open(fname, "w", encoding="utf-8") as f: f.write(template) except OSError as err: EricMessageBox.critical( self.__ui, self.tr("New Form"), self.tr( "<p>The new form file <b>{0}</b> could" " not be created.</p><p>Problem: {1}</p>" ).format(fname, str(err)), ) return self.__ericProject.appendFile(fname) self.__formsBrowser.sourceFile.emit(fname) ################################################################## ## methods below implement virtual environment handling ################################################################## def getVirtualEnvironment(self): """ Public method to get the path of the virtual environment. @return path of the virtual environment @rtype str """ language = self.__ericProject.getProjectLanguage() if language == "Python3": # get project specific virtual environment name venvName = self.getData("flask", "virtual_environment_name") if not venvName: venvName = self.__plugin.getPreferences("VirtualEnvironmentNamePy3") else: venvName = "" virtEnv = ( self.__virtualEnvManager.getVirtualenvDirectory(venvName) if venvName else "" ) if virtEnv and not os.path.exists(virtEnv): virtEnv = "" return virtEnv # __IGNORE_WARNING_M834__ def getVirtualenvInterpreter(self): """ Public method to get the path of the Python interpreter to be used with the current project. @return path of the Python interpreter @rtype str """ return self.getFullCommand("python") def getFullCommand(self, command, virtualEnvPath=None): """ Public method to get the full command for a given command name. @param command command name @type str @param virtualEnvPath path of the virtual environment @type str @return full command @rtype str """ virtualEnv = virtualEnvPath or self.getVirtualEnvironment() fullCmds = ( [ os.path.join(virtualEnv, "Scripts", command + ".exe"), os.path.join(virtualEnv, "bin", command + ".exe"), command, ] # fall back to just cmd if isWindowsPlatform() else [ os.path.join(virtualEnv, "bin", command), os.path.join(virtualEnv, "local", "bin", command), getExecutablePath(command), command, ] # fall back to just cmd ) for command in fullCmds: if os.path.exists(command): break return command ################################################################## ## methods 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() self.__migrateProject.projectClosed() 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 = getExecutablePaths("flask") for fullCmd in fullCmds: try: with open(fullCmd, "r", encoding="utf-8") as f: l0 = f.readline() except 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 getFlaskCommand(self): """ Public method to build the Flask command. @return full flask command @rtype str """ return self.getFullCommand("flask") @pyqtSlot() def __flaskInfo(self): """ Private slot to show some info about Flask. """ versions = self.getFlaskVersionStrings() url = "https://palletsprojects.com/p/flask/" msgBox = EricMessageBox.EricMessageBox( EricMessageBox.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>", "Do not translate the program names.", ).format(versions["flask"], versions["werkzeug"], versions["python"], url), modal=True, buttons=EricMessageBox.Ok, parent=self.__ui, ) msgBox.setIconPixmap( EricPixmapCache.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.__ericProject.getMainScript(normalized=True) if not mainScript: EricMessageBox.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.__ericProject.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.__ericProject.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.__ericProject.setData( "PROJECTTYPESPECIFICDATA", category, self.__projectData[category] ) def __determineCapabilities(self): """ Private method to determine capabilities provided by supported extensions. """ # 1. support for flask-babel (i.e. pybabel) self.__pybabelProject.determineCapability() # 2. support for flask-migrate self.__migrateProject.determineCapability() def hasCapability(self, key): """ Public method to check, if a capability is available. @param key key of the capability to check @type str @return flag indicating the availability of the capability @rtype bool """ try: return self.__capabilities[key] except KeyError: return False def setCapability(self, key, available): """ Public method to set the availability status of a capability. @param key key of the capability to set @type str @param available flag indicating the availability of the capability @type bool """ self.__capabilities[key] = available ################################################################## ## slots below implements project specific flask configuration ################################################################## @pyqtSlot() def __configureFlaskForProject(self): """ Private slot to configure the project specific flask parameters. """ from .FlaskConfigDialog import FlaskConfigDialog config = self.getData("flask", "") dlg = FlaskConfigDialog(config, self, parent=self.__ui) if dlg.exec() == QDialog.DialogCode.Accepted: config = dlg.getConfiguration() self.setData("flask", "", config) self.__setIgnoreVirtualEnvironment() self.__setDebugEnvironment() self.__migrateProject.determineCapability() self.__pybabelProject.determineCapability() self.projectClosedHooks() self.projectOpenedHooks() def __setIgnoreVirtualEnvironment(self): """ Private method to add an embedded project specific virtual environment to the list of ignore files/directories. """ virtenvName = self.getData("flask", "virtual_environment_name") if virtenvName: virtenvPath = self.getVirtualEnvironment() if self.__ericProject.startswithProjectPath(virtenvPath): relVirtenvPath = self.__ericProject.getRelativeUniversalPath( virtenvPath ) try: # code path for eric 22.12 and above fileTypes = self.__ericProject.getProjectData(dataKey="FILETYPES") fileTypes[relVirtenvPath] = "__IGNORE__" self.__ericProject.setProjectData(fileTypes, dataKey="FILETYPES") except AttributeError: # older versions access pdata directly if relVirtenvPath not in self.__ericProject.pdata["FILETYPES"]: self.__ericProject.pdata["FILETYPES"][ relVirtenvPath ] = "__IGNORE__" self.__ericProject.setDirty(True) def __setDebugEnvironment(self): """ Private method to set the virtual environment as the selected debug environment. """ language = self.__ericProject.getProjectLanguage() if language == "Python3": # get project specific virtual environment name venvName = self.getData("flask", "virtual_environment_name") if not venvName: venvName = self.__plugin.getPreferences("VirtualEnvironmentNamePy3") if venvName: self.__ericProject.debugProperties["VIRTUALENV"] = venvName ################################################################## ## slot below implements documentation function ################################################################## 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] = 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.ProcessState.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