--- a/ProjectFlask/Project.py Thu Nov 19 20:19:55 2020 +0100 +++ b/ProjectFlask/Project.py Sat Nov 21 17:50:57 2020 +0100 @@ -8,6 +8,7 @@ """ import os +import re from PyQt5.QtCore import ( pyqtSlot, QObject, QProcess, QProcessEnvironment, QTimer @@ -24,8 +25,11 @@ import Utilities from .FlaskCommandDialog import FlaskCommandDialog +from .PyBabelCommandDialog import PyBabelCommandDialog +# TODO: move PyBabel related code to a separate package (FlaskBabelExtension) +# TODO: move database related code to a separate package (FlaskMigrateExtension) class Project(QObject): """ Class implementing the Flask project support. @@ -67,6 +71,11 @@ "flask": "", "werkzeug": "", } + + self.__capabilities = { + "pybabel": False, + "migrate": False, + } def initActions(self): """ @@ -178,7 +187,7 @@ self.actions.append(self.initDatabaseAct) ################################## - ## database action below ## + ## pybabel action below ## ################################## self.pybabelConfigAct = E5Action( @@ -243,19 +252,18 @@ 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.addSeparator() menu.addAction(self.runPythonShellAct) - menu.addSection("flask routes") + menu.addSeparator() menu.addAction(self.showRoutesAct) - menu.addSection("flask init-db") + menu.addSeparator() menu.addAction(self.initDatabaseAct) - menu.addSection(self.tr("Translations")) + menu.addSeparator() menu.addAction(self.pybabelConfigAct) - menu.addSection(self.tr("Various")) + menu.addSeparator() menu.addAction(self.documentationAct) menu.addSeparator() menu.addAction(self.aboutFlaskAct) @@ -308,13 +316,16 @@ Public method to add our hook methods. """ if self.__e5project.getProjectType() == "Flask": + # TODO: add some methods for standard templates ## self.__formsBrowser = ( ## e5App().getObject("ProjectBrowser") ## .getProjectBrowser("forms")) ## self.__formsBrowser.addHookMethodAndMenuEntry( ## "newForm", self.newForm, self.tr("New template...")) ## - if self.flaskBabelAvailable(): + self.__determineCapabilities() + + if self.__capabilities["pybabel"]: self.__e5project.projectLanguageAddedByCode.connect( self.__projectLanguageAdded) self.__translationsBrowser = ( @@ -333,8 +344,15 @@ "generateAll", self.updateCatalogs, self.tr("Update All Catalogs")) self.__translationsBrowser.addHookMethodAndMenuEntry( + "generateAllWithObsolete", self.updateCatalogsObsolete, + self.tr("Update All Catalogs (with obsolete)")) + self.__translationsBrowser.addHookMethodAndMenuEntry( "generateSelected", self.updateSelectedCatalogs, self.tr("Update Selected Catalogs")) + self.__translationsBrowser.addHookMethodAndMenuEntry( + "generateSelectedWithObsolete", + self.updateSelectedCatalogsObsolete, + self.tr("Update Selected Catalogs (with obsolete)")) self.__hooksInstalled = True @@ -350,12 +368,22 @@ ## 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.removeHookMethod( + "extractMessages") + self.__translationsBrowser.removeHookMethod( + "releaseAll") + self.__translationsBrowser.removeHookMethod( + "releaseSelected") + self.__translationsBrowser.removeHookMethod( + "generateAll") + self.__translationsBrowser.removeHookMethod( + "generateAllWithObsolete") + self.__translationsBrowser.removeHookMethod( + "generateSelected") + self.__translationsBrowser.removeHookMethod( + "generateSelectedWithObsolete") + self.__translationsBrowser.removeHookMethod( + "open") self.__translationsBrowser = None self.__hooksInstalled = False @@ -649,8 +677,33 @@ self.__e5project.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.__capabilities["pybabel"] = self.flaskBabelAvailable() + self.pybabelConfigAct.setEnabled(self.__capabilities["pybabel"]) + + # 2. support for flask-migrate + # TODO: add support for flask-migrate + ################################################################## - ## slots below implement documentation functions + ## slot below implements project specific flask configuration + ################################################################## + + @pyqtSlot() + def __configureFlaskForProject(self): + """ + Private slot to configure the project specific flask parameters. + """ + # TODO: implement the flask project config dialog + # 1. check boxes to override flask-babel and flask-migrate + # 2. support for project specific virtual environment + + ################################################################## + ## slot below implements documentation function ################################################################## def __showDocumentation(self): @@ -759,13 +812,14 @@ dlg.show() self.__routesDialog = dlg + # TODO: replace this by commands made by flask-migrate (flask db ...) @pyqtSlot() def __initDatabase(self): """ Private slot showing the result of the database creation. """ dlg = FlaskCommandDialog(self) - if dlg.startFlaskCommand("init-db"): + if dlg.startCommand("init-db"): dlg.exec() ################################################################## @@ -799,7 +853,6 @@ """ Private slot to show a dialog to edit the pybabel configuration. """ - # TODO: implement this from .PyBabelConfigDialog import PyBabelConfigDialog config = self.getData("pybabel", "") @@ -808,6 +861,12 @@ config = dlg.getConfiguration() self.setData("pybabel", "", config) + self.__e5project.setTranslationPattern(os.path.join( + config["translationsDirectory"], "%language%", "LC_MESSAGES", + "{0}.po".format(config["domain"]) + )) + self.__e5project.setDirty(True) + cfgFileName = self.__e5project.getAbsoluteUniversalPath( config["configFile"]) if not os.path.exists(cfgFileName): @@ -840,6 +899,8 @@ """ Private method to create a template pybabel configuration file. + @param configFile name of the configuration file to be created + @type str @return flag indicating successful configuration file creation @rtype bool """ @@ -878,9 +939,30 @@ ) return False - def __projectLanguageAdded(self, code): - # TODO: implement this with pybabel ... - pass + def __getLocale(self, filename): + """ + Private method to extract the locale out of a file name. + + @param filename name of the file used for extraction + @type str + @return extracted locale + @rtype str or None + """ + if self.__e5project.getTranslationPattern(): + filename = os.path.splitext(filename)[0] + ".po" + + # On Windows, path typically contains backslashes. This leads + # to an invalid search pattern '...\(' because the opening bracket + # will be escaped. + pattern = self.__e5project.getTranslationPattern() + pattern = os.path.normpath(pattern) + pattern = pattern.replace("%language%", "(.*?)") + pattern = pattern.replace('\\', '\\\\') + match = re.search(pattern, filename) + if match is not None: + return match.group(1) + + return None def openPOEditor(self, poFile): """ @@ -907,6 +989,7 @@ """ title = self.tr("Extract messages") if self.__ensurePybabelConfigured(): + workdir = self.getApplication()[0] potFile = self.__e5project.getAbsoluteUniversalPath( self.getData("pybabel", "catalogFile")) @@ -918,41 +1001,251 @@ args = [ "-F", - self.__e5project.getAbsoluteUniversalPath( - self.getData("pybabel", "configFile")) + os.path.relpath( + self.__e5project.getAbsoluteUniversalPath( + self.getData("pybabel", "configFile")), + workdir + ) ] if self.getData("pybabel", "markersList"): for marker in self.getData("pybabel", "markersList"): args += ["-k", marker] args += [ "-o", - potFile, + os.path.relpath(potFile, workdir), "." ] - dlg = FlaskCommandDialog(self) - res = dlg.startBabelCommand( - "extract", args, title, + dlg = PyBabelCommandDialog( + self, title, msgSuccess=self.tr("\nMessages extracted successfully.") ) + res = dlg.startCommand("extract", args, workdir) if res: dlg.exec() self.__e5project.appendFile(potFile) - # TODO: implement this with pybabel ... - pass - def compileCatalogs(self): - # TODO: implement this with pybabel ... - pass + def __projectLanguageAdded(self, code): + """ + Private slot handling the addition of a new language. + + @param code language code of the new language + @type str + """ + title = self.tr( + "Initializing message catalog for '{0}'").format(code) + + if self.__ensurePybabelConfigured(): + workdir = self.getApplication()[0] + langFile = self.__e5project.getAbsoluteUniversalPath( + self.__e5project.getTranslationPattern().replace( + "%language%", code)) + potFile = self.__e5project.getAbsoluteUniversalPath( + self.getData("pybabel", "catalogFile")) + + args = [ + "--domain={0}".format(self.getData("pybabel", "domain")), + "--input-file={0}".format(os.path.relpath(potFile, workdir)), + "--output-file={0}".format(os.path.relpath(langFile, workdir)), + "--locale={0}".format(code), + ] + + dlg = PyBabelCommandDialog( + self, title, + msgSuccess=self.tr( + "\nMessage catalog initialized successfully.") + ) + res = dlg.startCommand("init", args, workdir) + if res: + dlg.exec() + + self.__e5project.appendFile(langFile) + + def compileCatalogs(self, filenames): + """ + Public method to compile the message catalogs. + + @param filenames list of filenames (not used) + @type list of str + """ + title = self.tr("Compiling message catalogs") + + if self.__ensurePybabelConfigured(): + workdir = self.getApplication()[0] + translationsDirectory = self.__e5project.getAbsoluteUniversalPath( + self.getData("pybabel", "translationsDirectory")) + + args = [ + "--domain={0}".format(self.getData("pybabel", "domain")), + "--directory={0}".format( + os.path.relpath(translationsDirectory, workdir)), + "--use-fuzzy", + "--statistics", + ] + + dlg = PyBabelCommandDialog( + self, title, + msgSuccess=self.tr("\nMessage catalogs compiled successfully.") + ) + res = dlg.startCommand("compile", args, workdir) + if res: + dlg.exec() + + for entry in os.walk(translationsDirectory): + for fileName in entry[2]: + fullName = os.path.join(entry[0], fileName) + if fullName.endswith('.mo'): + self.__e5project.appendFile(fullName) - def compileSelectedCatalogs(self): - # TODO: implement this with pybabel ... - pass + def compileSelectedCatalogs(self, filenames): + """ + Public method to update the message catalogs. + + @param filenames list of file names + @type list of str + """ + title = self.tr("Compiling message catalogs") + + locales = {self.__getLocale(f) for f in filenames} + + if len(locales) == 0: + E5MessageBox.warning( + self.__ui, + title, + self.tr('No locales detected. Aborting...')) + return + + if self.__ensurePybabelConfigured(): + workdir = self.getApplication()[0] + translationsDirectory = self.__e5project.getAbsoluteUniversalPath( + self.getData("pybabel", "translationsDirectory")) + + argsList = [] + for loc in locales: + argsList.append([ + "compile", + "--domain={0}".format(self.getData("pybabel", "domain")), + "--directory={0}".format( + os.path.relpath(translationsDirectory, workdir)), + "--use-fuzzy", + "--statistics", + "--locale={0}".format(loc), + ]) + + dlg = PyBabelCommandDialog( + self, title=title, + msgSuccess=self.tr("\nMessage catalogs compiled successfully.") + ) + res = dlg.startBatchCommand(argsList, workdir) + if res: + dlg.exec() + + for entry in os.walk(translationsDirectory): + for fileName in entry[2]: + fullName = os.path.join(entry[0], fileName) + if fullName.endswith('.mo'): + self.__e5project.appendFile(fullName) - def updateCatalogs(self): - # TODO: implement this with pybabel ... - pass + def updateCatalogs(self, filenames, withObsolete=False): + """ + Public method to update the message catalogs. + + @param filenames list of filenames (not used) + @type list of str + @param withObsolete flag indicating to keep obsolete translations + @type bool + """ + title = self.tr("Updating message catalogs") + + if self.__ensurePybabelConfigured(): + workdir = self.getApplication()[0] + translationsDirectory = self.__e5project.getAbsoluteUniversalPath( + self.getData("pybabel", "translationsDirectory")) + potFile = self.__e5project.getAbsoluteUniversalPath( + self.getData("pybabel", "catalogFile")) + + args = [ + "--domain={0}".format(self.getData("pybabel", "domain")), + "--input-file={0}".format(os.path.relpath(potFile, workdir)), + "--output-dir={0}".format( + os.path.relpath(translationsDirectory, workdir)), + ] + if not withObsolete: + args.append("--ignore-obsolete") + + dlg = PyBabelCommandDialog( + self, title, + msgSuccess=self.tr("\nMessage catalogs updated successfully.") + ) + res = dlg.startCommand("update", args, workdir) + if res: + dlg.exec() + + def updateCatalogsObsolete(self, filenames): + """ + Public method to update the message catalogs keeping obsolete + translations. + + @param filenames list of filenames (not used) + @type list of str + """ + self.updateCatalogs(filenames, withObsolete=True) - def updateSelectedCatalogs(self): - # TODO: implement this with pybabel ... - pass + def updateSelectedCatalogs(self, filenames, withObsolete=False): + """ + Public method to update the selected message catalogs. + + @param filenames list of filenames + @type list of str + @param withObsolete flag indicating to keep obsolete translations + @type bool + """ + title = self.tr("Updating message catalogs") + + locales = {self.__getLocale(f) for f in filenames} + + if len(locales) == 0: + E5MessageBox.warning( + self.__ui, + title, + self.tr('No locales detected. Aborting...')) + return + + if self.__ensurePybabelConfigured(): + workdir = self.getApplication()[0] + translationsDirectory = self.__e5project.getAbsoluteUniversalPath( + self.getData("pybabel", "translationsDirectory")) + potFile = self.__e5project.getAbsoluteUniversalPath( + self.getData("pybabel", "catalogFile")) + argsList = [] + for loc in locales: + args = [ + "update", + "--domain={0}".format(self.getData("pybabel", "domain")), + "--input-file={0}".format( + os.path.relpath(potFile, workdir)), + "--output-dir={0}".format( + os.path.relpath(translationsDirectory, workdir)), + "--locale={0}".format(loc), + ] + if not withObsolete: + args.append("--ignore-obsolete") + argsList.append(args) + + dlg = PyBabelCommandDialog( + self, title=title, + msgSuccess=self.tr("\nMessage catalogs updated successfully.") + ) + res = dlg.startBatchCommand(argsList, workdir) + if res: + dlg.exec() + + def updateSelectedCatalogsObsolete(self, filenames): + """ + Public method to update the message catalogs keeping obsolete + translations. + + @param filenames list of filenames (not used) + @type list of str + """ + self.updateSelectedCatalogs(filenames, withObsolete=True)