Sat, 21 Nov 2020 20:37:54 +0100
Moved the flask-babel support into its own package.
# -*- 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)