15.1.944. tablet_qt/tools/build_client_translations.py

#!/usr/bin/env python

"""
tools/build_server_translations.py

===============================================================================

    Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
    Created by Rudolf Cardinal (rnc1001@cam.ac.uk).

    This file is part of CamCOPS.

    CamCOPS is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    CamCOPS is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.

===============================================================================

**Make translation files for the CamCOPS C++ client, via Qt Linguist.**

For developer use only.

"""

import argparse
import logging
import os
from os.path import abspath, dirname, join
import shutil
import subprocess
import sys
from typing import Iterable, List
import xml.etree.ElementTree as ET

from cardinal_pythonlib.logs import (
    BraceStyleAdapter,
    main_only_quicksetup_rootlogger,
)
from cardinal_pythonlib.subproc import check_call_verbose

from camcops_server.cc_modules.cc_argparse import (
    RawDescriptionArgumentDefaultsRichHelpFormatter,
)

log = BraceStyleAdapter(logging.getLogger(__name__))

CURRENT_DIR = dirname(abspath(__file__))  # camcops/tablet_qt/tools
TABLET_QT_DIR = abspath(join(CURRENT_DIR, os.pardir))  # camcops/tablet_qt
CAMCOPS_PRO_FILE = join(TABLET_QT_DIR, "camcops.pro")
TRANSLATIONS_DIR = join(TABLET_QT_DIR, "translations")  # .ts and .qm live here

ENVVAR_LCONVERT = "LCONVERT"
ENVVAR_LRELEASE = "LRELEASE"
ENVVAR_LUPDATE = "LUPDATE"
ENVVAR_POEDIT = "POEDIT"

EXT_PO = ".po"
EXT_TS = ".ts"

OP_PO_TO_TS = "po2ts"
OP_SRC_TO_TS = "update"
OP_MISSING = "missing"
OP_TS_TO_QM = "release"
OP_TS_TO_PO = "ts2po"
OP_POEDIT = "poedit"
OP_ALL = "all"
ALL_OPERATIONS = [
    OP_PO_TO_TS,
    OP_SRC_TO_TS,
    OP_MISSING,
    OP_TS_TO_PO,
    OP_TS_TO_QM,
    OP_POEDIT,
    OP_ALL,
]
EXIT_SUCCESS = 0
EXIT_FAILURE = 1

# =============================================================================
# Support functions
# =============================================================================


def run(cmdargs: List[str]) -> None:
    """
    Runs a sub-command.

    Raises:
        :exc:CalledProcessError on failure

    Note that it is *critical* to abort on error; otherwise, for example, a
    process breaks the .pot file, and then this script chugs on and uses the
    broken .po file to break (for example) your Danish .po file.
    """
    check_call_verbose(cmdargs, log_level=logging.DEBUG)


def spawn(cmdargs: List[str]) -> None:
    """
    Runs a sub-command, detaching it so it runs separately.

    See
    https://stackoverflow.com/questions/1196074/how-to-start-a-background-process-in-python
    """
    subprocess.Popen(cmdargs, close_fds=True)


def change_extension(filename: str, new_ext: str) -> str:
    """
    Returns the same filename but with a different extension.
    The extension SHOULD START WITH A DOT.
    """
    return os.path.splitext(filename)[0] + new_ext


def first_file_newer_than_second(first: str, second: str) -> bool:
    """
    Compare file modification timestamps as the function name suggests.
    """
    first_time = os.path.getmtime(first)
    second_time = os.path.getmtime(second)
    return first_time > second_time


def convert_language_file_if_source_newer(
    source_filename: str, dest_filename: str, lconvert: str
) -> None:
    """
    Converts a .po file to a .ts file (or vice versa), if either (a) the
    destination doesn't exist, or (b) the source is newer.
    """
    if not os.path.exists(dest_filename) or first_file_newer_than_second(
        source_filename, dest_filename
    ):
        log.info(f"Converting {source_filename} -> {dest_filename}")
        run(
            [
                lconvert,
                "-locations",
                "relative",
                source_filename,
                "-o",
                dest_filename,
            ]
        )
        # Now, we have converted from source to destination. The destination
        # file will therefore be marked as newer. But this will lead to the
        # newer file being converted back to the older, the next time round,
        # and confusion. So now we want to mark the destination as having the
        # SAME timestamps as the source.
        # We can't set just the mtime, so we need to read-and-set the atime.
        dest_atime = os.path.getatime(dest_filename)
        source_mtime = os.path.getmtime(source_filename)
        os.utime(dest_filename, (dest_atime, source_mtime))  # change mtime
        # https://docs.python.org/3/library/os.html#os.utime


def gen_files_with_ext(directory: str, ext: str) -> Iterable[str]:
    """
    Yields all filenames within 'directory' that end in the specified
    extension. This function does NOT traverse subdirectories.

    See e.g.
    https://stackoverflow.com/questions/3964681/find-all-files-in-a-directory-with-extension-txt-in-python
    """
    for root, dirs, files in os.walk(directory):
        for filename in files:
            if filename.endswith(ext):
                fullpath = os.path.join(root, filename)
                yield fullpath


def report_missing_translations() -> int:
    exit_code = EXIT_SUCCESS
    for ts_filename in gen_files_with_ext(TRANSLATIONS_DIR, EXT_TS):
        missing = []
        tree = ET.parse(ts_filename)
        ts = tree.getroot()

        for context in ts.findall("context"):
            line = 0
            filename = ""

            for message in context.findall("message"):
                for location in message.findall("location"):
                    new_filename = location.attrib.get("filename")

                    if new_filename is not None:
                        filename = new_filename
                        line = 0

                    line_diff = location.attrib.get("line", 0)
                    line += int(line_diff)

                translation = message.find("translation")
                if translation.attrib.get("type", "") == "unfinished":
                    source = message.find("source").text
                    missing.append(
                        dict(filename=filename, line=line, source=source)
                    )

        if missing:
            exit_code = EXIT_FAILURE
            print(f"Missing translations found in: {ts_filename}:")
            for entry in missing:
                filename = entry["filename"]
                line = entry["line"]
                source = entry["source"]
                print(f"File: {filename}, line: {line}\n{source}\n")

    return exit_code


# =============================================================================
# Command-line entry point
# =============================================================================


def main() -> None:
    """
    Create translation files for the CamCOPS client.
    """
    # noinspection PyTypeChecker
    parser = argparse.ArgumentParser(
        description=f"""
Create translation files for CamCOPS client.

Operations:

    {OP_PO_TO_TS}
        Special. Converts all .po files to .ts files in the translations
        directory, if and only if the .po file is newer than the .ts file (or
        the .ts file doesn't exist).

    {OP_SRC_TO_TS}
        Updates all .ts files (which are XML, one per language) from the .pro
        file and thence the C++ source code.

    [At this stage, you could edit the .ts files with Qt Linguist. If you can't
    find it, use Qt Creator and look within your project in "Other files" /
    "Translations", right-click a .ts file, and then "Open With" / "Qt
    Linguist".]

    {OP_TS_TO_PO}
        Special. Converts all Qt .ts files to .po files in the translations
        directory, if and only if the .ts file is newer than the .po file (or
        the .po file doesn't exist).

    {OP_TS_TO_QM}
        Updates all .qm files (which are binary) from the corresponding .ts
        files (discovered via the .pro file).

    {OP_POEDIT}
        Launch (spawn) Poedit to edit the .po files.

    {OP_ALL}
        Executes all other operations in sequence, except {OP_POEDIT}.
        This should be safe, and allow you to use .po editors like Poedit. Run
        this script before and after editing.""",
        formatter_class=RawDescriptionArgumentDefaultsRichHelpFormatter,
    )
    parser.add_argument(
        "operation",
        choices=ALL_OPERATIONS,
        metavar="operation",
        help=f"Operation to perform; possibilities are {ALL_OPERATIONS!r}",
    )
    parser.add_argument(
        "--lconvert",
        help=f"Path to 'lconvert' tool (part of Qt Linguist). "
        f"Default is taken from {ENVVAR_LCONVERT} environment variable "
        f"or 'which lconvert'.",
        default=os.environ.get(ENVVAR_LCONVERT) or shutil.which("lconvert"),
    )
    parser.add_argument(
        "--lrelease",
        help=f"Path to 'lrelease' tool (part of Qt Linguist). "
        f"Default is taken from {ENVVAR_LRELEASE} environment variable "
        f"or 'which lrelease'.",
        default=os.environ.get(ENVVAR_LRELEASE) or shutil.which("lrelease"),
    )
    parser.add_argument(
        "--lupdate",
        help=f"Path to 'lupdate' tool (part of Qt Linguist). "
        f"Default is taken from {ENVVAR_LUPDATE} environment variable "
        f"or 'which lupdate'.",
        default=os.environ.get(ENVVAR_LUPDATE) or shutil.which("lupdate"),
    )
    parser.add_argument(
        "--poedit",
        help=f"Path to 'poedit' tool. "
        f"Default is taken from {ENVVAR_POEDIT} environment variable "
        f"or 'which poedit'.",
        default=os.environ.get(ENVVAR_POEDIT) or shutil.which("poedit"),
    )
    parser.add_argument(
        "--trim",
        dest="trim",
        action="store_true",
        help="Remove redundant strings.",
        default=True,
    )
    parser.add_argument(
        "--no_trim",
        dest="trim",
        action="store_true",
        help="Do not remove redundant strings.",
        default=False,
    )
    parser.add_argument("--verbose", action="store_true", help="Be verbose")
    args = parser.parse_args()
    main_only_quicksetup_rootlogger(
        level=logging.DEBUG if args.verbose else logging.INFO
    )
    op = args.operation  # type: str

    if op in (OP_PO_TO_TS, OP_ALL):
        log.debug(
            f"Copying all {EXT_PO} files to corresponding {EXT_TS} files if "
            f"the {EXT_PO} file is newer (or the {EXT_TS} file doesn't "
            f"exist)."
        )
        for source_filename in gen_files_with_ext(TRANSLATIONS_DIR, EXT_PO):
            dest_filename = change_extension(source_filename, EXT_TS)
            convert_language_file_if_source_newer(
                source_filename=source_filename,
                dest_filename=dest_filename,
                lconvert=args.lconvert,
            )

    if op in (OP_SRC_TO_TS, OP_ALL):
        assert args.lupdate, "Missing lupdate"
        log.info(
            f"Using Qt Linguist 'lupdate' to update .ts files "
            f"from {CAMCOPS_PRO_FILE}"
        )
        options = ["-no-obsolete"] if args.trim else []
        cmdargs = [args.lupdate] + options + [CAMCOPS_PRO_FILE]
        run(cmdargs)

    if op == OP_MISSING:
        exit_code = report_missing_translations()

        sys.exit(exit_code)

    if op in (OP_TS_TO_PO, OP_ALL):
        log.debug(
            f"Copying all {EXT_TS} files to corresponding {EXT_PO} files if "
            f"the {EXT_PO} file is newer (or the {EXT_PO} file doesn't "
            f"exist)."
        )
        for source_filename in gen_files_with_ext(TRANSLATIONS_DIR, EXT_TS):
            dest_filename = change_extension(source_filename, EXT_PO)
            convert_language_file_if_source_newer(
                source_filename=source_filename,
                dest_filename=dest_filename,
                lconvert=args.lconvert,
            )

    if op in (OP_TS_TO_QM, OP_ALL):
        assert args.lrelease, "Missing lrelease"
        log.info(
            f"Using Qt Linguist '{args.lrelease}' to update .qm files "
            "from .ts files"
        )
        cmdargs = [args.lrelease, CAMCOPS_PRO_FILE]
        run(cmdargs)

    if op in (OP_POEDIT,):  # but not OP_ALL
        for po_filename in gen_files_with_ext(TRANSLATIONS_DIR, EXT_PO):
            cmdargs = [args.poedit, po_filename]
            spawn(cmdargs)


if __name__ == "__main__":
    main()