Done implementing pybabel translations support.

Sat, 21 Nov 2020 17:50:57 +0100

author
Detlev Offenbach <detlev@die-offenbachs.de>
date
Sat, 21 Nov 2020 17:50:57 +0100
changeset 16
dd3f6bfb85f7
parent 15
3f5c05eb2d5f
child 17
f31df56510a1

Done implementing pybabel translations support.

PluginFlask.e4p file | annotate | diff | comparison | revisions
PluginProjectFlask.py file | annotate | diff | comparison | revisions
ProjectFlask/FlaskCommandDialog.py file | annotate | diff | comparison | revisions
ProjectFlask/Project.py file | annotate | diff | comparison | revisions
ProjectFlask/PyBabelCommandDialog.py file | annotate | diff | comparison | revisions
ProjectFlask/PyBabelConfigDialog.py file | annotate | diff | comparison | revisions
ProjectFlask/PyBabelConfigDialog.ui file | annotate | diff | comparison | revisions
diff -r 3f5c05eb2d5f -r dd3f6bfb85f7 PluginFlask.e4p
--- a/PluginFlask.e4p	Thu Nov 19 20:19:55 2020 +0100
+++ b/PluginFlask.e4p	Sat Nov 21 17:50:57 2020 +0100
@@ -21,6 +21,7 @@
     <Source>ProjectFlask/FlaskBabelDetector.py</Source>
     <Source>ProjectFlask/FlaskCommandDialog.py</Source>
     <Source>ProjectFlask/Project.py</Source>
+    <Source>ProjectFlask/PyBabelCommandDialog.py</Source>
     <Source>ProjectFlask/PyBabelConfigDialog.py</Source>
     <Source>ProjectFlask/RoutesDialog.py</Source>
     <Source>ProjectFlask/RunServerDialog.py</Source>
diff -r 3f5c05eb2d5f -r dd3f6bfb85f7 PluginProjectFlask.py
--- a/PluginProjectFlask.py	Thu Nov 19 20:19:55 2020 +0100
+++ b/PluginProjectFlask.py	Sat Nov 21 17:50:57 2020 +0100
@@ -427,7 +427,7 @@
                     "Flask",
                     self.tr("Pyramid"), self.fileTypesCallback,
                     lexerAssociationCallback=self.lexerAssociationCallback,
-#                    binaryTranslationsCallback=self.binaryTranslationsCallback,
+                    binaryTranslationsCallback=self.binaryTranslationsCallback,
                     progLanguages=self.__supportedVariants[:])
     
     def getMenu(self, name):
diff -r 3f5c05eb2d5f -r dd3f6bfb85f7 ProjectFlask/FlaskCommandDialog.py
--- a/ProjectFlask/FlaskCommandDialog.py	Thu Nov 19 20:19:55 2020 +0100
+++ b/ProjectFlask/FlaskCommandDialog.py	Sat Nov 21 17:50:57 2020 +0100
@@ -19,30 +19,39 @@
     """
     Class implementing a dialog to run a flask command and show its output.
     """
-    def __init__(self, project, parent=None):
+    def __init__(self, project, title="", msgSuccess="", msgError="",
+                 parent=None):
         """
         Constructor
         
         @param project reference to the project object
         @type Project
+        @param title window title of the dialog
+        @type str
+        @param msgSuccess success message to be shown
+        @type str
+        @param msgError message to be shown on error
+        @type str
         @param parent reference to the parent widget
         @type QWidget
         """
         super(FlaskCommandDialog, self).__init__(parent)
         self.setupUi(self)
         
+        if title:
+            self.setWindowTitle(title)
+        
         self.__project = project
+        self.__successMessage = msgSuccess
+        self.__errorMessage = msgError
         
         self.__process = None
         
-        self.successMessage = ""
-        self.errorMessage = ""
-        
         self.buttonBox.button(QDialogButtonBox.Close).setEnabled(True)
         self.buttonBox.button(QDialogButtonBox.Close).setDefault(True)
         self.buttonBox.button(QDialogButtonBox.Cancel).setEnabled(False)
     
-    def startFlaskCommand(self, command, args=None):
+    def startCommand(self, command, args=None):
         """
         Public method to start a flask command and show its output.
         
@@ -89,61 +98,6 @@
         
         return ok
     
-    def startBabelCommand(self, command, args, title, msgSuccess="",
-                          msgError=""):
-        """
-        Public method to start a pybabel command and show its output.
-        
-        @param command pybabel command to be run
-        @type str
-        @param args list of command line arguments for the command
-        @type list of str
-        @param title window title of the dialog
-        @type str
-        @param msgSuccess success message to be shown
-        @type str
-        @param msgError message to be shown on error
-        @type str
-        @return flag indicating a successful start
-        @rtype bool
-        """
-        self.setWindowTitle(title)
-        
-        self.successMessage = msgSuccess
-        self.errorMessage = msgError
-        
-        workdir, _ = self.__project.getApplication()
-        babelCommand = self.__project.getBabelCommand()
-        
-        self.__process = QProcess()
-        self.__process.setWorkingDirectory(workdir)
-        self.__process.setProcessChannelMode(QProcess.MergedChannels)
-        
-        self.__process.readyReadStandardOutput.connect(self.__readStdOut)
-        self.__process.finished.connect(self.__processFinished)
-        
-        self.outputEdit.clear()
-        
-        babelArgs = [command]
-        if args:
-            babelArgs += args
-        
-        self.__process.start(babelCommand, babelArgs)
-        ok = self.__process.waitForStarted(10000)
-        if not ok:
-            E5MessageBox.critical(
-                None,
-                self.tr("Execute PyBabel Command"),
-                self.tr("""The pybabel process could not be started."""))
-        else:
-            self.buttonBox.button(QDialogButtonBox.Close).setEnabled(False)
-            self.buttonBox.button(QDialogButtonBox.Cancel).setDefault(True)
-            self.buttonBox.button(QDialogButtonBox.Cancel).setEnabled(True)
-            self.buttonBox.button(QDialogButtonBox.Cancel).setFocus(
-                Qt.OtherFocusReason)
-        
-        return ok
-    
     def closeEvent(self, evt):
         """
         Protected method handling the close event of the dialog.
@@ -181,10 +135,10 @@
         self.buttonBox.button(QDialogButtonBox.Close).setFocus(
             Qt.OtherFocusReason)
         
-        if normal and self.successMessage:
-            self.outputEdit.insertPlainText(self.successMessage)
-        elif not normal and self.errorMessage:
-            self.outputEdit.insertPlainText(self.errorMessage)
+        if normal and self.__successMessage:
+            self.outputEdit.insertPlainText(self.__successMessage)
+        elif not normal and self.__errorMessage:
+            self.outputEdit.insertPlainText(self.__errorMessage)
     
     @pyqtSlot()
     def __cancelProcess(self):
diff -r 3f5c05eb2d5f -r dd3f6bfb85f7 ProjectFlask/Project.py
--- a/ProjectFlask/Project.py	Thu Nov 19 20:19:55 2020 +0100
+++ b/ProjectFlask/Project.py	Sat Nov 21 17:50:57 2020 +0100
@@ -8,6 +8,7 @@
 """
 
 import os
+import re
 
 from PyQt5.QtCore import (
     pyqtSlot, QObject, QProcess, QProcessEnvironment, QTimer
@@ -24,8 +25,11 @@
 import Utilities
 
 from .FlaskCommandDialog import FlaskCommandDialog
+from .PyBabelCommandDialog import PyBabelCommandDialog
 
 
+# TODO: move PyBabel related code to a separate package (FlaskBabelExtension)
+# TODO: move database related code to a separate package (FlaskMigrateExtension)
 class Project(QObject):
     """
     Class implementing the Flask project support.
@@ -67,6 +71,11 @@
             "flask": "",
             "werkzeug": "",
         }
+        
+        self.__capabilities = {
+            "pybabel": False,
+            "migrate": False,
+        }
     
     def initActions(self):
         """
@@ -178,7 +187,7 @@
         self.actions.append(self.initDatabaseAct)
         
         ##################################
-        ## database action below        ##
+        ## pybabel action below         ##
         ##################################
         
         self.pybabelConfigAct = E5Action(
@@ -243,19 +252,18 @@
         menu = QMenu(self.tr('&Flask'), self.__ui)
         menu.setTearOffEnabled(True)
         
-        menu.addSection("flask run")
         menu.addAction(self.runServerAct)
         menu.addAction(self.runDevServerAct)
         menu.addAction(self.askForServerOptionsAct)
-        menu.addSection("flask shell")
+        menu.addSeparator()
         menu.addAction(self.runPythonShellAct)
-        menu.addSection("flask routes")
+        menu.addSeparator()
         menu.addAction(self.showRoutesAct)
-        menu.addSection("flask init-db")
+        menu.addSeparator()
         menu.addAction(self.initDatabaseAct)
-        menu.addSection(self.tr("Translations"))
+        menu.addSeparator()
         menu.addAction(self.pybabelConfigAct)
-        menu.addSection(self.tr("Various"))
+        menu.addSeparator()
         menu.addAction(self.documentationAct)
         menu.addSeparator()
         menu.addAction(self.aboutFlaskAct)
@@ -308,13 +316,16 @@
         Public method to add our hook methods.
         """
         if self.__e5project.getProjectType() == "Flask":
+            # TODO: add some methods for standard templates
 ##            self.__formsBrowser = (
 ##                e5App().getObject("ProjectBrowser")
 ##                .getProjectBrowser("forms"))
 ##            self.__formsBrowser.addHookMethodAndMenuEntry(
 ##                "newForm", self.newForm, self.tr("New template..."))
 ##            
-            if self.flaskBabelAvailable():
+            self.__determineCapabilities()
+            
+            if self.__capabilities["pybabel"]:
                 self.__e5project.projectLanguageAddedByCode.connect(
                     self.__projectLanguageAdded)
                 self.__translationsBrowser = (
@@ -333,8 +344,15 @@
                     "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
             
@@ -350,12 +368,22 @@
 ##            
             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("generateSelected")
-            self.__translationsBrowser.removeHookMethod("open")
+            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
@@ -649,8 +677,33 @@
         self.__e5project.setData(
             "PROJECTTYPESPECIFICDATA", category, self.__projectData[category])
     
+    def __determineCapabilities(self):
+        """
+        Private method to determine capabilities provided by supported
+        extensions.
+        """
+        # 1. support for flask-babel (i.e. pybabel)
+        self.__capabilities["pybabel"] = self.flaskBabelAvailable()
+        self.pybabelConfigAct.setEnabled(self.__capabilities["pybabel"])
+        
+        # 2. support for flask-migrate
+        # TODO: add support for flask-migrate
+    
     ##################################################################
-    ## slots below implement documentation functions
+    ## slot below implements project specific flask configuration
+    ##################################################################
+    
+    @pyqtSlot()
+    def __configureFlaskForProject(self):
+        """
+        Private slot to configure the project specific flask parameters.
+        """
+        # TODO: implement the flask project config dialog
+        # 1. check boxes to override flask-babel and flask-migrate
+        # 2. support for project specific virtual environment
+    
+    ##################################################################
+    ## slot below implements documentation function
     ##################################################################
     
     def __showDocumentation(self):
@@ -759,13 +812,14 @@
             dlg.show()
             self.__routesDialog = dlg
     
+    # TODO: replace this by commands made by flask-migrate (flask db ...)
     @pyqtSlot()
     def __initDatabase(self):
         """
         Private slot showing the result of the database creation.
         """
         dlg = FlaskCommandDialog(self)
-        if dlg.startFlaskCommand("init-db"):
+        if dlg.startCommand("init-db"):
             dlg.exec()
     
     ##################################################################
@@ -799,7 +853,6 @@
         """
         Private slot to show a dialog to edit the pybabel configuration.
         """
-        # TODO: implement this
         from .PyBabelConfigDialog import PyBabelConfigDialog
         
         config = self.getData("pybabel", "")
@@ -808,6 +861,12 @@
             config = dlg.getConfiguration()
             self.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):
@@ -840,6 +899,8 @@
         """
         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
         """
@@ -878,9 +939,30 @@
             )
             return False
     
-    def __projectLanguageAdded(self, code):
-        # TODO: implement this with pybabel ...
-        pass
+    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):
         """
@@ -907,6 +989,7 @@
         """
         title = self.tr("Extract messages")
         if self.__ensurePybabelConfigured():
+            workdir = self.getApplication()[0]
             potFile = self.__e5project.getAbsoluteUniversalPath(
                 self.getData("pybabel", "catalogFile"))
             
@@ -918,41 +1001,251 @@
             
             args = [
                 "-F",
-                self.__e5project.getAbsoluteUniversalPath(
-                    self.getData("pybabel", "configFile"))
+                os.path.relpath(
+                    self.__e5project.getAbsoluteUniversalPath(
+                        self.getData("pybabel", "configFile")),
+                    workdir
+                )
             ]
             if self.getData("pybabel", "markersList"):
                 for marker in self.getData("pybabel", "markersList"):
                     args += ["-k", marker]
             args += [
                 "-o",
-                potFile,
+                os.path.relpath(potFile, workdir),
                 "."
             ]
             
-            dlg = FlaskCommandDialog(self)
-            res = dlg.startBabelCommand(
-                "extract", args, title,
+            dlg = PyBabelCommandDialog(
+                self, title,
                 msgSuccess=self.tr("\nMessages extracted successfully.")
             )
+            res = dlg.startCommand("extract", args, workdir)
             if res:
                 dlg.exec()
                 self.__e5project.appendFile(potFile)
-        # TODO: implement this with pybabel ...
-        pass
     
-    def compileCatalogs(self):
-        # TODO: implement this with pybabel ...
-        pass
+    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.getApplication()[0]
+            langFile = self.__e5project.getAbsoluteUniversalPath(
+                self.__e5project.getTranslationPattern().replace(
+                    "%language%", code))
+            potFile = self.__e5project.getAbsoluteUniversalPath(
+                self.getData("pybabel", "catalogFile"))
+            
+            args = [
+                "--domain={0}".format(self.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.getApplication()[0]
+            translationsDirectory = self.__e5project.getAbsoluteUniversalPath(
+                self.getData("pybabel", "translationsDirectory"))
+            
+            args = [
+                "--domain={0}".format(self.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):
-        # TODO: implement this with pybabel ...
-        pass
+    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.getApplication()[0]
+            translationsDirectory = self.__e5project.getAbsoluteUniversalPath(
+                self.getData("pybabel", "translationsDirectory"))
+            
+            argsList = []
+            for loc in locales:
+                argsList.append([
+                    "compile",
+                    "--domain={0}".format(self.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):
-        # TODO: implement this with pybabel ...
-        pass
+    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.getApplication()[0]
+            translationsDirectory = self.__e5project.getAbsoluteUniversalPath(
+                self.getData("pybabel", "translationsDirectory"))
+            potFile = self.__e5project.getAbsoluteUniversalPath(
+                self.getData("pybabel", "catalogFile"))
+            
+            args = [
+                "--domain={0}".format(self.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):
-        # TODO: implement this with pybabel ...
-        pass
+    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.getApplication()[0]
+            translationsDirectory = self.__e5project.getAbsoluteUniversalPath(
+                self.getData("pybabel", "translationsDirectory"))
+            potFile = self.__e5project.getAbsoluteUniversalPath(
+                self.getData("pybabel", "catalogFile"))
+            argsList = []
+            for loc in locales:
+                args = [
+                    "update",
+                    "--domain={0}".format(self.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)
diff -r 3f5c05eb2d5f -r dd3f6bfb85f7 ProjectFlask/PyBabelCommandDialog.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/ProjectFlask/PyBabelCommandDialog.py	Sat Nov 21 17:50:57 2020 +0100
@@ -0,0 +1,202 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2020 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+"""
+Module implementing a dialog to run a flask command and show its output.
+"""
+
+from PyQt5.QtCore import pyqtSlot, Qt, QProcess, QTimer
+from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QAbstractButton
+
+from E5Gui import E5MessageBox
+
+from .Ui_FlaskCommandDialog import Ui_FlaskCommandDialog
+
+
+class PyBabelCommandDialog(QDialog, Ui_FlaskCommandDialog):
+    """
+    Class implementing a dialog to run a flask command and show its output.
+    """
+    def __init__(self, project, title="", msgSuccess="", msgError="",
+                 parent=None):
+        """
+        Constructor
+        
+        @param project reference to the project object
+        @type Project
+        @param title window title of the dialog
+        @type str
+        @param msgSuccess success message to be shown
+        @type str
+        @param msgError message to be shown on error
+        @type str
+        @param parent reference to the parent widget
+        @type QWidget
+        """
+        super(PyBabelCommandDialog, self).__init__(parent)
+        self.setupUi(self)
+        
+        if title:
+            self.setWindowTitle(title)
+        
+        self.__project = project
+        self.__successMessage = msgSuccess
+        self.__errorMessage = msgError
+        
+        self.__process = None
+        self.__argsLists = []
+        self.__workdir = ""
+        
+        self.buttonBox.button(QDialogButtonBox.Close).setEnabled(True)
+        self.buttonBox.button(QDialogButtonBox.Close).setDefault(True)
+        self.buttonBox.button(QDialogButtonBox.Cancel).setEnabled(False)
+    
+    def startCommand(self, command, args, workdir, clearOutput=True):
+        """
+        Public method to start a pybabel command and show its output.
+        
+        @param command pybabel command to be run
+        @type str
+        @param args list of command line arguments for the command
+        @type list of str
+        @param workdir working directory for the command
+        @type str
+        @param clearOutput flag indicating to clear the output
+        @type bool
+        @return flag indicating a successful start
+        @rtype bool
+        """
+        babelCommand = self.__project.getBabelCommand()
+        
+        self.__process = QProcess()
+        self.__process.setWorkingDirectory(workdir)
+        self.__process.setProcessChannelMode(QProcess.MergedChannels)
+        
+        self.__process.readyReadStandardOutput.connect(self.__readStdOut)
+        self.__process.finished.connect(self.__processFinished)
+        
+        if clearOutput:
+            self.outputEdit.clear()
+        
+        babelArgs = [command]
+        if args:
+            babelArgs += args
+        
+        self.__process.start(babelCommand, babelArgs)
+        ok = self.__process.waitForStarted(10000)
+        if not ok:
+            E5MessageBox.critical(
+                None,
+                self.tr("Execute PyBabel Command"),
+                self.tr("""The pybabel process could not be started."""))
+        else:
+            self.buttonBox.button(QDialogButtonBox.Close).setEnabled(False)
+            self.buttonBox.button(QDialogButtonBox.Cancel).setDefault(True)
+            self.buttonBox.button(QDialogButtonBox.Cancel).setEnabled(True)
+            self.buttonBox.button(QDialogButtonBox.Cancel).setFocus(
+                Qt.OtherFocusReason)
+        
+        return ok
+    
+    def startBatchCommand(self, argsLists, workdir):
+        """
+        Public method to start a pybabel command repeatedly with a list of
+        arguments and show the output.
+        
+        @param argsLists list of command line arguments for the batch commands
+        @type list of lists of str
+        @param workdir working directory for the command
+        @type str
+        @return flag indicating a successful start of the first process
+        @rtype bool
+        """
+        self.__argsLists = argsLists[:]
+        self.__workdir = workdir
+        
+        # start the first process
+        args = self.__argsLists.pop(0)
+        res = self.startCommand(args[0], args[1:], workdir)
+        if not res:
+            self.__argsLists = []
+        
+        return res
+    
+    def closeEvent(self, evt):
+        """
+        Protected method handling the close event of the dialog.
+        
+        @param evt reference to the close event object
+        @type QCloseEvent
+        """
+        self.__argsLists = []
+        self.__cancelProcess()
+        evt.accept()
+    
+    @pyqtSlot()
+    def __readStdOut(self):
+        """
+        Private slot to add the server process output to the output pane.
+        """
+        if self.__process is not None:
+            out = str(self.__process.readAllStandardOutput(), "utf-8")
+            self.outputEdit.insertPlainText(out)
+    
+    def __processFinished(self, exitCode, exitStatus):
+        """
+        Private slot connected to the finished signal.
+        
+        @param exitCode exit code of the process
+        @type int
+        @param exitStatus exit status of the process
+        @type QProcess.ExitStatus
+        """
+        normal = (exitStatus == QProcess.NormalExit) and (exitCode == 0)
+        self.__cancelProcess()
+        
+        if self.__argsLists:
+            args = self.__argsLists.pop(0)
+            self.startCommand(args[0], args[1:], self.__workdir,
+                              clearOutput=False)
+            return
+        
+        self.buttonBox.button(QDialogButtonBox.Cancel).setEnabled(False)
+        self.buttonBox.button(QDialogButtonBox.Close).setEnabled(True)
+        self.buttonBox.button(QDialogButtonBox.Close).setDefault(True)
+        self.buttonBox.button(QDialogButtonBox.Close).setFocus(
+            Qt.OtherFocusReason)
+        
+        if normal and self.__successMessage:
+            self.outputEdit.insertPlainText(self.__successMessage)
+        elif not normal and self.__errorMessage:
+            self.outputEdit.insertPlainText(self.__errorMessage)
+    
+    @pyqtSlot()
+    def __cancelProcess(self):
+        """
+        Private slot to terminate the current process.
+        """
+        if (
+            self.__process is not None and
+            self.__process.state() != QProcess.NotRunning
+        ):
+            self.__process.terminate()
+            QTimer.singleShot(2000, self.__process.kill)
+            self.__process.waitForFinished(3000)
+        
+        self.__process = None
+    
+    @pyqtSlot(QAbstractButton)
+    def on_buttonBox_clicked(self, button):
+        """
+        Private slot handling presses of the button box buttons.
+        
+        @param button reference to the button been clicked
+        @type QAbstractButton
+        """
+        if button is self.buttonBox.button(QDialogButtonBox.Close):
+            self.close()
+        elif button is self.buttonBox.button(QDialogButtonBox.Cancel):
+            self.__argsLists = []
+            self.__cancelProcess()
diff -r 3f5c05eb2d5f -r dd3f6bfb85f7 ProjectFlask/PyBabelConfigDialog.py
--- a/ProjectFlask/PyBabelConfigDialog.py	Thu Nov 19 20:19:55 2020 +0100
+++ b/ProjectFlask/PyBabelConfigDialog.py	Sat Nov 21 17:50:57 2020 +0100
@@ -7,6 +7,8 @@
 Module implementing a dialog to edit the PyBabel configuration.
 """
 
+import os
+
 from PyQt5.QtCore import pyqtSlot, Qt
 from PyQt5.QtWidgets import QDialog, QDialogButtonBox
 
@@ -43,6 +45,11 @@
         self.configFilePicker.setDefaultDirectory(
             self.__e5project.getProjectPath())
         
+        self.translationsDirectoryPicker.setMode(
+            E5PathPickerModes.DirectoryMode)
+        self.translationsDirectoryPicker.setDefaultDirectory(
+            self.__e5project.getProjectPath())
+        
         self.catalogFilePicker.setMode(
             E5PathPickerModes.SaveFileEnsureExtensionMode)
         self.catalogFilePicker.setFilters(self.tr(
@@ -60,6 +67,12 @@
             self.configFilePicker.setText(
                 self.__e5project.getAbsoluteUniversalPath(
                     configuration["configFile"]))
+        if "translationsDirectory" in configuration:
+            self.translationsDirectoryPicker.setText(
+                self.__e5project.getAbsoluteUniversalPath(
+                    configuration["translationsDirectory"]))
+        if "domain" in configuration:
+            self.domainEdit.setText(configuration["domain"])
         if "catalogFile" in configuration:
             self.catalogFilePicker.setText(
                 self.__e5project.getAbsoluteUniversalPath(
@@ -80,21 +93,50 @@
         configuration = {
             "configFile": self.__e5project.getRelativeUniversalPath(
                 self.configFilePicker.text()),
-            "catalogFile": self.__e5project.getRelativeUniversalPath(
-                self.catalogFilePicker.text()),
+            "translationsDirectory": self.__e5project.getRelativeUniversalPath(
+                self.translationsDirectoryPicker.text()),
         }
+        
+        domain = self.domainEdit.text()
+        if domain:
+            configuration["domain"] = domain
+        else:
+            configuration["domain"] = "messages"
+        
+        catalogFile = self.catalogFilePicker.text()
+        if not catalogFile:
+            # use a default name made of translations dir and domain
+            catalogFile = os.path.join(
+                configuration["translationsDirectory"],
+                "{0}.pot".format(configuration["domain"]))
+        configuration["catalogFile"] = (
+            self.__e5project.getRelativeUniversalPath(catalogFile)
+        )
+        
         if self.markersEdit.text():
             configuration["markersList"] = self.markersEdit.text().split()
         
         return configuration
     
     def __updateOK(self):
+        """
+        Private method to update the status of the OK button.
+        """
         enable = (
             bool(self.configFilePicker.text()) and
-            bool(self.catalogFilePicker.text())
+            bool(self.translationsDirectoryPicker.text())
         )
         self.buttonBox.button(QDialogButtonBox.Ok).setEnabled(enable)
     
+    def __updateCatalogPicker(self):
+        """
+        Private method to update the contents of the catalog picker.
+        """
+        translationsDirectory = self.translationsDirectoryPicker.text()
+        domain = self.domainEdit.text()
+        self.catalogFilePicker.setText(os.path.join(
+            translationsDirectory, "{0}.pot".format(domain)))
+    
     @pyqtSlot(str)
     def on_configFilePicker_textChanged(self, txt):
         """
@@ -106,7 +148,7 @@
         self.__updateOK()
     
     @pyqtSlot(str)
-    def on_catalogFilePicker_textChanged(self, txt):
+    def on_translationsDirectoryPicker_textChanged(self, txt):
         """
         Private slot to handle a change of the catalog file name.
         
@@ -114,3 +156,14 @@
         @type str
         """
         self.__updateOK()
+        self.__updateCatalogPicker()
+    
+    @pyqtSlot(str)
+    def on_domainEdit_textChanged(self, txt):
+        """
+        Private slot to handle a change of the translations domain.
+        
+        @param txt entered translations domain
+        @type str
+        """
+        self.__updateCatalogPicker()
diff -r 3f5c05eb2d5f -r dd3f6bfb85f7 ProjectFlask/PyBabelConfigDialog.ui
--- a/ProjectFlask/PyBabelConfigDialog.ui	Thu Nov 19 20:19:55 2020 +0100
+++ b/ProjectFlask/PyBabelConfigDialog.ui	Sat Nov 21 17:50:57 2020 +0100
@@ -7,7 +7,7 @@
     <x>0</x>
     <y>0</y>
     <width>600</width>
-    <height>124</height>
+    <height>150</height>
    </rect>
   </property>
   <property name="windowTitle">
@@ -24,40 +24,6 @@
      </property>
     </widget>
    </item>
-   <item row="1" column="0">
-    <widget class="QLabel" name="label_2">
-     <property name="text">
-      <string>Message Catalog:</string>
-     </property>
-    </widget>
-   </item>
-   <item row="2" column="0">
-    <widget class="QLabel" name="label_3">
-     <property name="text">
-      <string>Translation Markers:</string>
-     </property>
-    </widget>
-   </item>
-   <item row="2" column="1">
-    <widget class="QLineEdit" name="markersEdit">
-     <property name="toolTip">
-      <string>Enter the translation markers separated by space (_ is included by default)</string>
-     </property>
-     <property name="clearButtonEnabled">
-      <bool>true</bool>
-     </property>
-    </widget>
-   </item>
-   <item row="3" column="0" colspan="2">
-    <widget class="QDialogButtonBox" name="buttonBox">
-     <property name="orientation">
-      <enum>Qt::Horizontal</enum>
-     </property>
-     <property name="standardButtons">
-      <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
-     </property>
-    </widget>
-   </item>
    <item row="0" column="1">
     <widget class="E5PathPicker" name="configFilePicker" native="true">
      <property name="sizePolicy">
@@ -74,7 +40,54 @@
      </property>
     </widget>
    </item>
+   <item row="1" column="0">
+    <widget class="QLabel" name="label_4">
+     <property name="text">
+      <string>Translations Directory:</string>
+     </property>
+    </widget>
+   </item>
    <item row="1" column="1">
+    <widget class="E5PathPicker" name="translationsDirectoryPicker" native="true">
+     <property name="sizePolicy">
+      <sizepolicy hsizetype="Expanding" vsizetype="Preferred">
+       <horstretch>0</horstretch>
+       <verstretch>0</verstretch>
+      </sizepolicy>
+     </property>
+     <property name="focusPolicy">
+      <enum>Qt::StrongFocus</enum>
+     </property>
+     <property name="toolTip">
+      <string>Enter the name of the directory containing the translations</string>
+     </property>
+    </widget>
+   </item>
+   <item row="2" column="0">
+    <widget class="QLabel" name="label_5">
+     <property name="text">
+      <string>Domain:</string>
+     </property>
+    </widget>
+   </item>
+   <item row="2" column="1">
+    <widget class="QLineEdit" name="domainEdit">
+     <property name="toolTip">
+      <string>Enter the name of the translations domain (leave empty for default)</string>
+     </property>
+     <property name="clearButtonEnabled">
+      <bool>true</bool>
+     </property>
+    </widget>
+   </item>
+   <item row="3" column="0">
+    <widget class="QLabel" name="label_2">
+     <property name="text">
+      <string>Message Catalog:</string>
+     </property>
+    </widget>
+   </item>
+   <item row="3" column="1">
     <widget class="E5PathPicker" name="catalogFilePicker" native="true">
      <property name="sizePolicy">
       <sizepolicy hsizetype="Expanding" vsizetype="Preferred">
@@ -90,6 +103,33 @@
      </property>
     </widget>
    </item>
+   <item row="4" column="0">
+    <widget class="QLabel" name="label_3">
+     <property name="text">
+      <string>Translation Markers:</string>
+     </property>
+    </widget>
+   </item>
+   <item row="4" column="1">
+    <widget class="QLineEdit" name="markersEdit">
+     <property name="toolTip">
+      <string>Enter the translation markers separated by space (_ is included by default)</string>
+     </property>
+     <property name="clearButtonEnabled">
+      <bool>true</bool>
+     </property>
+    </widget>
+   </item>
+   <item row="5" column="0" colspan="2">
+    <widget class="QDialogButtonBox" name="buttonBox">
+     <property name="orientation">
+      <enum>Qt::Horizontal</enum>
+     </property>
+     <property name="standardButtons">
+      <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
+     </property>
+    </widget>
+   </item>
   </layout>
  </widget>
  <customwidgets>
@@ -102,6 +142,8 @@
  </customwidgets>
  <tabstops>
   <tabstop>configFilePicker</tabstop>
+  <tabstop>translationsDirectoryPicker</tabstop>
+  <tabstop>domainEdit</tabstop>
   <tabstop>catalogFilePicker</tabstop>
   <tabstop>markersEdit</tabstop>
  </tabstops>

eric ide

mercurial