ProjectFlask/FlaskBabelExtension/PyBabelProjectExtension.py

Sat, 23 Dec 2023 15:48:52 +0100

author
Detlev Offenbach <detlev@die-offenbachs.de>
date
Sat, 23 Dec 2023 15:48:52 +0100
branch
eric7
changeset 83
d8788dc3442f
parent 82
bb14c648099b
child 84
f39230b845e4
permissions
-rw-r--r--

Updated copyright for 2024.

# -*- coding: utf-8 -*-

# Copyright (c) 2020 - 2024 Detlev Offenbach <detlev@die-offenbachs.de>
#

"""
Module implementing the project support for flask-babel.
"""

import contextlib
import os
import re

from PyQt6.QtCore import QObject, QProcess, pyqtSlot
from PyQt6.QtWidgets import QDialog, QMenu

from eric7.EricGui.EricAction import EricAction
from eric7.EricWidgets import EricMessageBox
from eric7.EricWidgets.EricApplication import ericApp

try:
    from eric7.SystemUtilities.FileSystemUtilities import isinpath
except ImportError:
    # imports for eric < 23.1
    from eric7.Utilities import isinpath

from .PyBabelCommandDialog import PyBabelCommandDialog


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.__ericProject = ericApp().getObject("Project")

        self.__hooksInstalled = False

    def initActions(self):
        """
        Public method to define the flask-babel actions.
        """
        self.actions = []

        self.pybabelConfigAct = EricAction(
            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 = EricAction(
            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 = EricAction(
            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.__ericProject.projectLanguageAddedByCode.connect(
                self.__projectLanguageAdded
            )
            self.__translationsBrowser = (
                ericApp().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.__ericProject.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 isinpath(interpreter):
            detector = os.path.join(os.path.dirname(__file__), "FlaskBabelDetector.py")
            proc = QProcess()
            proc.setProcessChannelMode(QProcess.ProcessChannelMode.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.DialogCode.Accepted:
            config = dlg.getConfiguration()
            self.__project.setData("flask-babel", "", config)

            self.__ericProject.setTranslationPattern(
                os.path.join(
                    config["translationsDirectory"],
                    "%language%",
                    "LC_MESSAGES",
                    "{0}.po".format(config["domain"]),
                )
            )
            self.__ericProject.setDirty(True)

            cfgFileName = self.__ericProject.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.__ericProject.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.__ericProject.appendFile(configFile)
            EricMessageBox.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:
            EricMessageBox.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 = ericApp().getObject("Pip")
            pip.installPackages(["flask-babel"], interpreter=interpreter)
            self.determineCapability()
        else:
            EricMessageBox.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.")
        )
        EricMessageBox.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.__ericProject.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.__ericProject.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:
                EricMessageBox.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.__ericProject.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.__ericProject.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.__ericProject.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.__ericProject.getAbsoluteUniversalPath(
                self.__ericProject.getTranslationPattern().replace("%language%", code)
            )
            potFile = self.__ericProject.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.__ericProject.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.__ericProject.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.__ericProject.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:
            EricMessageBox.warning(
                self.__ui, title, self.tr("No locales detected. Aborting...")
            )
            return

        if self.__ensurePybabelConfigured():
            workdir = self.__project.getApplication()[0]
            translationsDirectory = self.__ericProject.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.__ericProject.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.__ericProject.getAbsoluteUniversalPath(
                self.__project.getData("flask-babel", "translationsDirectory")
            )
            potFile = self.__ericProject.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:
            EricMessageBox.warning(
                self.__ui, title, self.tr("No locales detected. Aborting...")
            )
            return

        if self.__ensurePybabelConfigured():
            workdir = self.__project.getApplication()[0]
            translationsDirectory = self.__ericProject.getAbsoluteUniversalPath(
                self.__project.getData("flask-babel", "translationsDirectory")
            )
            potFile = self.__ericProject.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)

eric ide

mercurial