Sat, 24 Apr 2021 11:30:11 +0200
- implemented some code simplifications
# -*- coding: utf-8 -*- # Copyright (c) 2020 - 2021 Detlev Offenbach <detlev@die-offenbachs.de> # """ Module implementing the project support for flask-babel. """ import os import re import contextlib from PyQt5.QtCore import pyqtSlot, QObject, QProcess from PyQt5.QtWidgets import QDialog, QMenu from E5Gui import E5MessageBox from E5Gui.E5Application import e5App from E5Gui.E5Action import E5Action from .PyBabelCommandDialog import PyBabelCommandDialog import Utilities class PyBabelProject(QObject): """ Class implementing the flask-babel 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().__init__(parent) self.__plugin = plugin self.__project = project self.__e5project = e5App().getObject("Project") self.__hooksInstalled = False def initActions(self): """ Public method to define the flask-babel actions. """ self.actions = [] self.pybabelConfigAct = E5Action( self.tr('Configure flask-babel'), self.tr('&Configure flask-babel'), 0, 0, self, 'flask_config_pybabel') self.pybabelConfigAct.setStatusTip(self.tr( 'Shows a dialog to edit the configuration for flask-babel')) self.pybabelConfigAct.setWhatsThis(self.tr( """<b>Configure flask-babel</b>""" """<p>Shows a dialog to edit the configuration for """ """flask-babel.</p>""" )) self.pybabelConfigAct.triggered.connect( self.__configurePyBabel) self.actions.append(self.pybabelConfigAct) self.pybabelInstallAct = E5Action( self.tr('Install flask-babel'), self.tr('&Install flask-babel'), 0, 0, self, 'flask_install_pybabel') self.pybabelInstallAct.setStatusTip(self.tr( 'Installs the flask-babel extension into the configured' ' environment')) self.pybabelInstallAct.setWhatsThis(self.tr( """<b>Install flask-babel</b>""" """<p>Installs the flask-babel extension into the configured""" """ environment using the pip interface.</p>""" )) self.pybabelInstallAct.triggered.connect( self.__installFlaskBabel) self.actions.append(self.pybabelInstallAct) self.pybabelAvailabilityAct = E5Action( self.tr('Check flask-babel Availability'), self.tr('Check flask-babel &Availability'), 0, 0, self, 'flask_check_pybabel') self.pybabelAvailabilityAct.setStatusTip(self.tr( 'Check the availability of the flask-babel extension')) self.pybabelAvailabilityAct.setWhatsThis(self.tr( """<b>Check flask-babel Availability</b>""" """<p>Check the availability of the flask-babel extension.</p>""" )) self.pybabelAvailabilityAct.triggered.connect( self.__checkAvailability) self.actions.append(self.pybabelAvailabilityAct) def initMenu(self): """ Public method to initialize the flask-babel menu. @return the menu generated @rtype QMenu """ menu = QMenu(self.tr("Translations")) menu.setTearOffEnabled(True) menu.addAction(self.pybabelConfigAct) menu.addSeparator() menu.addAction(self.pybabelAvailabilityAct) menu.addAction(self.pybabelInstallAct) return menu 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("flask-babel"): 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. """ available = ( self.__project.getData("flask", "flask_babel_available") if self.__project.getData("flask", "flask_babel_override") else self.__flaskBabelAvailable() ) self.__project.setCapability("flask-babel", available) self.pybabelConfigAct.setEnabled(available) self.pybabelInstallAct.setEnabled(not available) ################################################################## ## 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): """ Private method to check, if the 'flask-babel' package is available. @return flag indicating the availability of 'flask-babel' @rtype bool """ interpreter = self.__project.getVirtualenvInterpreter() 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. """ from .PyBabelConfigDialog import PyBabelConfigDialog config = self.__project.getData("flask-babel", "") dlg = PyBabelConfigDialog(config) if dlg.exec() == QDialog.Accepted: config = dlg.getConfiguration() self.__project.setData("flask-babel", "", 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("flask-babel", "") if not config: self.__configurePybabel() return True configFileName = self.__project.getData("flask-babel", "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.__project.getApplication() template = ( ("[python: {0}]\n" "[jinja2: templates/**.html]\n" "extensions=jinja2.ext.autoescape,jinja2.ext.with_\n") if app.endswith(".py") else ("[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 OSError 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 @pyqtSlot() def __installFlaskBabel(self): """ Private slot to install the flask-babel extension into the configured environment. """ venvName = self.__project.getVirtualEnvironment() if venvName: interpreter = self.__project.getFullCommand("python") pip = e5App().getObject("Pip") pip.installPackages(["flask-babel"], interpreter=interpreter) self.determineCapability() else: E5MessageBox.critical( None, self.tr("Install flask-babel"), self.tr("The 'flask-babel' extension could not be installed" " because no virtual environment has been" " configured.")) @pyqtSlot() def __checkAvailability(self): """ Private slot to check the availability of the 'flask-babel' extension. """ self.determineCapability() msg = ( self.tr("The 'flask-babel' extension is installed.") if self.__project.hasCapability("flask-babel") else self.tr("The 'flask-babel' extension is not installed.") ) E5MessageBox.information( None, self.tr("flask-babel Availability"), msg) 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("flask-babel", "catalogFile")) with contextlib.suppress(OSError): potFilePath = os.path.dirname(potFile) os.makedirs(potFilePath) args = [ "-F", os.path.relpath( self.__e5project.getAbsoluteUniversalPath( self.__project.getData("flask-babel", "configFile")), workdir ) ] if self.__project.getData("flask-babel", "markersList"): for marker in self.__project.getData("flask-babel", "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("flask-babel", "catalogFile")) args = [ "--domain={0}".format( self.__project.getData("flask-babel", "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("flask-babel", "translationsDirectory")) args = [ "--domain={0}".format( self.__project.getData("flask-babel", "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("flask-babel", "translationsDirectory")) argsList = [] for loc in locales: argsList.append([ "compile", "--domain={0}".format( self.__project.getData("flask-babel", "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("flask-babel", "translationsDirectory")) potFile = self.__e5project.getAbsoluteUniversalPath( self.__project.getData("flask-babel", "catalogFile")) args = [ "--domain={0}".format( self.__project.getData("flask-babel", "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("flask-babel", "translationsDirectory")) potFile = self.__e5project.getAbsoluteUniversalPath( self.__project.getData("flask-babel", "catalogFile")) argsList = [] for loc in locales: args = [ "update", "--domain={0}".format( self.__project.getData("flask-babel", "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)