--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/ProjectFlask/FlaskBabelExtension/PyBabelProjectExtension.py Sat Nov 21 20:37:54 2020 +0100 @@ -0,0 +1,578 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2020 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing the project support for flask-babel. +""" + +import os +import re + +from PyQt5.QtCore import pyqtSlot, QObject, QProcess +from PyQt5.QtWidgets import QDialog + +from E5Gui import E5MessageBox +from E5Gui.E5Application import e5App + +from .PyBabelCommandDialog import PyBabelCommandDialog + +import Utilities + + +class PyBabelProject(QObject): + """ + Class implementing the Flask project support. + """ + def __init__(self, plugin, project, parent=None): + """ + Constructor + + @param plugin reference to the plugin object + @type ProjectFlaskPlugin + @param project reference to the project object + @type Project + @param parent parent + @type QObject + """ + super(PyBabelProject, self).__init__(parent) + + self.__plugin = plugin + self.__project = project + + self.__e5project = e5App().getObject("Project") + self.__virtualEnvManager = e5App().getObject("VirtualEnvManager") + + self.__hooksInstalled = False + + 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.__project.hasCapability("pybabel"): + 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( + "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 + + self.registerOpenHook() + + def projectClosedHooks(self): + """ + Public method to remove our hook methods. + """ + if self.__hooksInstalled: + 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( + "generateAllWithObsolete") + self.__translationsBrowser.removeHookMethod( + "generateSelected") + self.__translationsBrowser.removeHookMethod( + "generateSelectedWithObsolete") + self.__translationsBrowser.removeHookMethod( + "open") + self.__translationsBrowser = None + + self.__hooksInstalled = False + + def determineCapability(self): + """ + Public method to determine the availability of flask-babel. + """ + self.__project.setCapability("pybabel", self.flaskBabelAvailable()) + + ################################################################## + ## slots and methods below implement general functionality + ################################################################## + + def getBabelCommand(self): + """ + Public method to build the Babel command. + + @return full pybabel command + @rtype str + """ + return self.__project.getFullCommand("pybabel") + + ################################################################## + ## 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): + """ + Public slot to show a dialog to edit the pybabel configuration. + """ + from .PyBabelConfigDialog import PyBabelConfigDialog + + config = self.__project.getData("pybabel", "") + dlg = PyBabelConfigDialog(config) + if dlg.exec() == QDialog.Accepted: + config = dlg.getConfiguration() + self.__project.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): + self.__createBabelCfg(cfgFileName) + + def __ensurePybabelConfigured(self): + """ + Private method to ensure, that PyBabel has been configured. + + @return flag indicating successful configuration + @rtype bool + """ + config = self.__project.getData("pybabel", "") + if not config: + self.__configurePybabel() + return True + + configFileName = self.__project.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. + + @param configFile name of the configuration file to be created + @type str + @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 __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): + """ + Public method to edit the given file in an external .po editor. + + @param poFile name of the .po file + @type str + """ + editor = self.__plugin.getPreferences("TranslationsEditor") + if poFile.endswith(".po") and editor: + workdir = self.__project.getApplication()[0] + started, pid = QProcess.startDetached(editor, [poFile], workdir) + if not started: + E5MessageBox.critical( + None, + self.tr('Process Generation Error'), + self.tr('The translations editor process ({0}) could' + ' not be started.').format( + os.path.basename(editor))) + + def extractMessages(self): + """ + Public method to extract the messages catalog template file. + """ + title = self.tr("Extract messages") + if self.__ensurePybabelConfigured(): + workdir = self.__project.getApplication()[0] + potFile = self.__e5project.getAbsoluteUniversalPath( + self.__project.getData("pybabel", "catalogFile")) + + try: + potFilePath = os.path.dirname(potFile) + os.makedirs(potFilePath) + except EnvironmentError: + pass + + args = [ + "-F", + os.path.relpath( + self.__e5project.getAbsoluteUniversalPath( + self.__project.getData("pybabel", "configFile")), + workdir + ) + ] + if self.__project.getData("pybabel", "markersList"): + for marker in self.__project.getData("pybabel", "markersList"): + args += ["-k", marker] + args += [ + "-o", + os.path.relpath(potFile, workdir), + "." + ] + + dlg = PyBabelCommandDialog( + self, title, + msgSuccess=self.tr("\nMessages extracted successfully.") + ) + res = dlg.startCommand("extract", args, workdir) + if res: + dlg.exec() + self.__e5project.appendFile(potFile) + + 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.__project.getApplication()[0] + langFile = self.__e5project.getAbsoluteUniversalPath( + self.__e5project.getTranslationPattern().replace( + "%language%", code)) + potFile = self.__e5project.getAbsoluteUniversalPath( + self.__project.getData("pybabel", "catalogFile")) + + args = [ + "--domain={0}".format( + self.__project.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.__project.getApplication()[0] + translationsDirectory = self.__e5project.getAbsoluteUniversalPath( + self.__project.getData("pybabel", "translationsDirectory")) + + args = [ + "--domain={0}".format( + self.__project.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, 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.__project.getApplication()[0] + translationsDirectory = self.__e5project.getAbsoluteUniversalPath( + self.__project.getData("pybabel", "translationsDirectory")) + + argsList = [] + for loc in locales: + argsList.append([ + "compile", + "--domain={0}".format( + self.__project.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, 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.__project.getApplication()[0] + translationsDirectory = self.__e5project.getAbsoluteUniversalPath( + self.__project.getData("pybabel", "translationsDirectory")) + potFile = self.__e5project.getAbsoluteUniversalPath( + self.__project.getData("pybabel", "catalogFile")) + + args = [ + "--domain={0}".format( + self.__project.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, 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.__project.getApplication()[0] + translationsDirectory = self.__e5project.getAbsoluteUniversalPath( + self.__project.getData("pybabel", "translationsDirectory")) + potFile = self.__e5project.getAbsoluteUniversalPath( + self.__project.getData("pybabel", "catalogFile")) + argsList = [] + for loc in locales: + args = [ + "update", + "--domain={0}".format( + self.__project.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)