ProjectFlask/FlaskBabelExtension/PyBabelProjectExtension.py

changeset 17
f31df56510a1
child 18
d76a0939be6a
--- /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)

eric ide

mercurial