ProjectFlask/FlaskBabelExtension/PyBabelProjectExtension.py

Sun, 22 Nov 2020 14:06:00 +0100

author
Detlev Offenbach <detlev@die-offenbachs.de>
date
Sun, 22 Nov 2020 14:06:00 +0100
changeset 18
d76a0939be6a
parent 17
f31df56510a1
child 26
5aac667c4f0f
permissions
-rw-r--r--

Added code ot create template web pages of selectable kinds.

# -*- 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 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
    
    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 OSError:
                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