Repository: freedomofpress/securedrop

Issue:
streamline functions currently provided by `i18n_tool.py`
- [x] Adapt `securedrop-client`'s `make extract-strings` target
    - Can this be generalized in `securedrop-tooling`?
    - Does this let us combine the `securedrop/{securedrop,desktop}` Weblate components?  (This might violate the "No side effects" constraint.)
- [x] Enforce in CI (`make check-strings`)
    - Can this be generalized in `securedrop-tooling`?
- Per <https://github.com/freedomofpress/securedrop-engineering/pull/30#issuecomment-1633171470>:
    - [x] Reimplement "Compile `.po` catalogs → `.mo` machine objects" as `make compile-translations`
- ~`make update-docs` → https://github.com/freedomofpress/securedrop-docs/blob/main/docs/includes/l10n.txt~
    - Counterpoint: https://github.com/freedomofpress/securedrop/issues/6917#issuecomment-1689091116 
    - freedomofpress/securedrop-docs#503
- ~~freedomofpress/securedrop-client#1280~~
    - Counterpoint: https://github.com/freedomofpress/securedrop/issues/6917#issuecomment-1690762840


Discussion:

Resolving patch:
diff --git a/securedrop/i18n.py b/securedrop/i18n.py
--- a/securedrop/i18n.py
+++ b/securedrop/i18n.py
@@ -211,11 +211,11 @@ def get_locale(config: SecureDropConfig) -> str:
     - config.DEFAULT_LOCALE
     - config.FALLBACK_LOCALE
     """
-    preferences = []
+    preferences: List[str] = []
     if session and session.get("locale"):
-        preferences.append(session.get("locale"))
+        preferences.append(session["locale"])
     if request.args.get("l"):
-        preferences.insert(0, request.args.get("l"))
+        preferences.insert(0, request.args["l"])
     if not preferences:
         preferences.extend(get_accepted_languages())
     preferences.append(config.DEFAULT_LOCALE)
diff --git a/securedrop/i18n_tool.py b/securedrop/i18n_tool.py
deleted file mode 100755
--- a/securedrop/i18n_tool.py
+++ /dev/null
@@ -1,738 +0,0 @@
-#!/opt/venvs/securedrop-app-code/bin/python
-
-import argparse
-import glob
-import json
-import logging
-import os
-import re
-import signal
-import subprocess
-import sys
-import textwrap
-from argparse import _SubParsersAction
-from os.path import abspath, dirname, join, realpath
-from pathlib import Path
-from typing import Dict, List, Optional, Set
-
-import version
-
-logging.basicConfig(format="%(asctime)s %(levelname)s %(message)s")
-log = logging.getLogger(__name__)
-
-I18N_CONF = os.path.join(os.path.dirname(__file__), "i18n.json")
-
-# Map components of the "securedrop" Weblate project (keys) to their filesystem
-# paths (values) relative to the repository root.
-LOCALE_DIR = {
-    "securedrop": "securedrop/translations",
-    "desktop": "install_files/ansible-base/roles/tails-config/templates",
-}
-
-
-class I18NTool:
-    #
-    # The database of support language, indexed by the language code
-    # used by weblate (i.e. whatever shows as CODE in
-    # https://weblate.securedrop.org/projects/securedrop/securedrop/CODE/
-    # is the index of the SUPPORTED_LANGUAGES database below.
-    #
-    # name: English name of the language to the documentation, not for
-    #       display in the interface.
-    # desktop: The language code used for desktop icons.
-    #
-    with open(I18N_CONF) as i18n_conf:
-        conf = json.load(i18n_conf)
-    supported_languages = conf["supported_locales"]
-    release_tag_re = re.compile(r"^\d+\.\d+\.\d+$")
-    translated_commit_re = re.compile("Translated using Weblate")
-    updated_commit_re = re.compile(r"(?:updated from|  (?:revision|commit):) (\w+)")
-
-    def file_is_modified(self, path: str) -> bool:
-        return bool(subprocess.call(["git", "-C", dirname(path), "diff", "--quiet", path]))
-
-    def ensure_i18n_remote(self, args: argparse.Namespace) -> None:
-        """
-        Make sure we have a git remote for the i18n repo.
-        """
-
-        k = {"cwd": args.root}
-        if "i18n" not in subprocess.check_output(["git", "remote"], encoding="utf-8", **k):
-            subprocess.check_call(["git", "remote", "add", "i18n", args.url], **k)
-        subprocess.check_call(["git", "fetch", "i18n"], **k)
-
-    def translate_messages(self, args: argparse.Namespace) -> None:
-        messages_file = Path(args.translations_dir).absolute() / "messages.pot"
-
-        if args.extract_update:
-            if not os.path.exists(args.translations_dir):
-                os.makedirs(args.translations_dir)
-            sources = args.sources.split(",")
-            subprocess.check_call(
-                [
-                    "pybabel",
-                    "extract",
-                    "--charset=utf-8",
-                    "--mapping",
-                    args.mapping,
-                    "--output",
-                    messages_file,
-                    "--project=SecureDrop",
-                    "--version",
-                    args.version,
-                    "--msgid-bugs-address=securedrop@freedom.press",
-                    "--copyright-holder=Freedom of the Press Foundation",
-                    "--add-comments=Translators:",
-                    "--strip-comments",
-                    "--add-location=never",
-                    "--no-wrap",
-                    *sources,
-                ]
-            )
-
-            msg_file_content = messages_file.read_text()
-            updated_content = _remove_from_content_line_with_text(
-                text='"POT-Creation-Date:', content=msg_file_content
-            )
-            messages_file.write_text(updated_content)
-
-            if (
-                self.file_is_modified(str(messages_file))
-                and len(os.listdir(args.translations_dir)) > 1
-            ):
-                tglob = f"{args.translations_dir}/*/LC_MESSAGES/*.po"
-                for translation in glob.iglob(tglob):
-                    subprocess.check_call(
-                        [
-                            "msgattrib",
-                            "--no-fuzzy",
-                            "--output-file",
-                            translation,
-                            translation,
-                        ]
-                    )
-                    subprocess.check_call(
-                        [
-                            "msgmerge",
-                            "--previous",
-                            "--update",
-                            "--no-fuzzy-matching",
-                            "--no-wrap",
-                            translation,
-                            messages_file,
-                        ]
-                    )
-                log.warning(f"messages translations updated in {messages_file}")
-            else:
-                log.warning("messages translations are already up to date")
-
-        if args.compile and len(os.listdir(args.translations_dir)) > 1:
-            # Suppress all pybabel to hide warnings (e.g.
-            # https://github.com/python-babel/babel/issues/566) and verbose "compiling..." messages
-            subprocess.run(
-                ["pybabel", "compile", "--directory", args.translations_dir],
-                check=True,
-                stdout=subprocess.DEVNULL,
-                stderr=subprocess.DEVNULL,
-            )
-
-    def translate_desktop(self, args: argparse.Namespace) -> None:
-        messages_file = Path(args.translations_dir).absolute() / "desktop.pot"
-
-        if args.extract_update:
-            sources = args.sources.split(",")
-            xgettext_flags = []
-
-            # First extract from JavaScript sources via Babel ("xgettext
-            # --language=JavaScript" can't parse gettext as exposed by GNOME
-            # JavaScript):
-            js_sources = [source for source in sources if ".js" in source]
-            if js_sources:
-                xgettext_flags.append("--join-existing")
-                subprocess.check_call(
-                    [
-                        "pybabel",
-                        "extract",
-                        "--charset=utf-8",
-                        "--mapping",
-                        args.mapping,
-                        "--output",
-                        "desktop.pot",
-                        "--project=SecureDrop",
-                        "--version",
-                        args.version,
-                        "--msgid-bugs-address=securedrop@freedom.press",
-                        "--copyright-holder=Freedom of the Press Foundation",
-                        "--add-comments=Translators:",
-                        "--strip-comments",
-                        "--add-location=never",
-                        "--no-wrap",
-                        *js_sources,
-                    ],
-                    cwd=args.translations_dir,
-                )
-
-            # Then extract from desktop templates via xgettext in appending
-            # "--join-existing" mode:
-            desktop_sources = [source for source in sources if ".js" not in source]
-            subprocess.check_call(
-                [
-                    "xgettext",
-                    *xgettext_flags,
-                    "--output=desktop.pot",
-                    "--language=Desktop",
-                    "--keyword",
-                    "--keyword=Name",
-                    "--package-version",
-                    args.version,
-                    "--msgid-bugs-address=securedrop@freedom.press",
-                    "--copyright-holder=Freedom of the Press Foundation",
-                    *desktop_sources,
-                ],
-                cwd=args.translations_dir,
-            )
-            msg_file_content = messages_file.read_text()
-            updated_content = _remove_from_content_line_with_text(
-                text='"POT-Creation-Date:', content=msg_file_content
-            )
-            messages_file.write_text(updated_content)
-
-            if self.file_is_modified(str(messages_file)):
-                for f in os.listdir(args.translations_dir):
-                    if not f.endswith(".po"):
-                        continue
-                    po_file = os.path.join(args.translations_dir, f)
-                    subprocess.check_call(
-                        ["msgmerge", "--no-fuzzy-matching", "--update", po_file, messages_file]
-                    )
-                log.warning(f"messages translations updated in {messages_file}")
-            else:
-                log.warning("desktop translations are already up to date")
-
-        if args.compile:
-            pos = [f for f in os.listdir(args.translations_dir) if f.endswith(".po")]
-            linguas = [lingua[:-3] for lingua in pos]
-            content = "\n".join(linguas) + "\n"
-            linguas_file = join(args.translations_dir, "LINGUAS")
-            try:
-                open(linguas_file, "w").write(content)
-
-                # First, compile each message catalog for normal gettext use.
-                # We have to iterate over them, rather than using "pybabel
-                # compile --directory", in order to replicate gettext's
-                # standard "{locale}/LC_MESSAGES/{domain}.mo" structure (like
-                # "securedrop/translations").
-                locale_dir = os.path.join(args.translations_dir, "locale")
-                for po in pos:
-                    locale = po.replace(".po", "")
-                    output_dir = os.path.join(locale_dir, locale, "LC_MESSAGES")
-                    subprocess.run(["mkdir", "--parents", output_dir], check=True)
-                    subprocess.run(
-                        [
-                            "msgfmt",
-                            "--output-file",
-                            os.path.join(output_dir, "messages.mo"),
-                            po,
-                        ],
-                        check=True,
-                        stdout=subprocess.DEVNULL,
-                        stderr=subprocess.DEVNULL,
-                        cwd=args.translations_dir,
-                    )
-
-                # Then use msgfmt to update the desktop files as usual.
-                desktop_sources = [
-                    source for source in args.sources.split(",") if ".js" not in source
-                ]
-                for source in desktop_sources:
-                    target = source.rstrip(".in")
-                    subprocess.check_call(
-                        [
-                            "msgfmt",
-                            "--desktop",
-                            "--template",
-                            source,
-                            "-o",
-                            target,
-                            "-d",
-                            ".",
-                        ],
-                        cwd=args.translations_dir,
-                    )
-                    self.sort_desktop_template(join(args.translations_dir, target))
-            finally:
-                if os.path.exists(linguas_file):
-                    os.unlink(linguas_file)
-
-    def sort_desktop_template(self, template: str) -> None:
-        """
-        Sorts the lines containing the icon names.
-        """
-        lines = open(template).readlines()
-        names = sorted(line for line in lines if line.startswith("Name"))
-        others = (line for line in lines if not line.startswith("Name"))
-        with open(template, "w") as new_template:
-            for line in others:
-                new_template.write(line)
-            for line in names:
-                new_template.write(line)
-
-    def set_translate_parser(
-        self, parser: argparse.ArgumentParser, translations_dir: str, sources: str
-    ) -> None:
-        parser.add_argument(
-            "--extract-update",
-            action="store_true",
-            help=("extract strings to translate and " "update existing translations"),
-        )
-        parser.add_argument("--compile", action="store_true", help="compile translations")
-        parser.add_argument(
-            "--translations-dir",
-            default=translations_dir,
-            help=f"Base directory for translation files (default {translations_dir})",
-        )
-        parser.add_argument(
-            "--version",
-            default=version.__version__,
-            help=(
-                "SecureDrop version "
-                "to store in pot files (default {})".format(version.__version__)
-            ),
-        )
-        parser.add_argument(
-            "--sources",
-            default=sources,
-            help=f"Source files and directories to extract (default {sources})",
-        )
-
-    def set_translate_messages_parser(self, subps: _SubParsersAction) -> None:
-        parser = subps.add_parser(
-            "translate-messages", help=("Update and compile " "source and template translations")
-        )
-        translations_dir = join(dirname(realpath(__file__)), "translations")
-        sources = ".,source_templates,journalist_templates"
-        self.set_translate_parser(parser, translations_dir, sources)
-        mapping = "babel.cfg"
-        parser.add_argument(
-            "--mapping",
-            default=mapping,
-            help=f"Mapping of files to consider (default {mapping})",
-        )
-        parser.set_defaults(func=self.translate_messages)
-
-    def set_translate_desktop_parser(self, subps: _SubParsersAction) -> None:
-        parser = subps.add_parser(
-            "translate-desktop", help=("Update and compile " "desktop icons translations")
-        )
-        translations_dir = join(
-            dirname(realpath(__file__)),
-            "..",
-            LOCALE_DIR["desktop"],
-        )
-        sources = ",".join(
-            [
-                "desktop-journalist-icon.j2.in",
-                "desktop-source-icon.j2.in",
-                "extension.js.in",
-            ]
-        )
-        mapping = join(dirname(realpath(__file__)), "babel.cfg")
-        parser.add_argument(
-            "--mapping",
-            default=mapping,
-            help=f"Mapping of files to consider (default {mapping})",
-        )
-        self.set_translate_parser(parser, translations_dir, sources)
-        parser.set_defaults(func=self.translate_desktop)
-
-    @staticmethod
-    def require_git_email_name(git_dir: str) -> bool:
-        cmd = (
-            "git -C {d} config --get user.name > /dev/null && "
-            "git -C {d} config --get user.email > /dev/null".format(d=git_dir)
-        )
-        # nosemgrep: python.lang.security.audit.subprocess-shell-true.subprocess-shell-true
-        if subprocess.call(cmd, shell=True):  # nosec
-            if "docker" in open("/proc/1/cgroup").read():
-                log.error(
-                    "remember ~/.gitconfig does not exist "
-                    "in the dev-shell Docker container, "
-                    "only .git/config does"
-                )
-            raise Exception(cmd + " returned false, please set name and email")
-        return True
-
-    def update_docs(self, args: argparse.Namespace) -> None:
-        l10n_content = ".. GENERATED BY i18n_tool.py DO NOT EDIT:\n\n"
-        for (code, info) in sorted(self.supported_languages.items()):
-            l10n_content += "* " + info["name"] + " (``" + code + "``)\n"
-        includes = abspath(join(args.docs_repo_dir, "docs/includes"))
-        l10n_txt = join(includes, "l10n.txt")
-        open(l10n_txt, mode="w").write(l10n_content)
-        self.require_git_email_name(includes)
-        if self.file_is_modified(l10n_txt):
-            subprocess.check_call(["git", "add", "l10n.txt"], cwd=includes)
-            msg = "docs: update the list of supported languages"
-            subprocess.check_call(["git", "commit", "-m", msg, "l10n.txt"], cwd=includes)
-            log.warning(l10n_txt + " updated")
-            git_show_out = subprocess.check_output(["git", "show"], encoding="utf-8", cwd=includes)
-            log.warning(git_show_out)
-        else:
-            log.warning(l10n_txt + " already up to date")
-
-    def set_update_docs_parser(self, subps: _SubParsersAction) -> None:
-        parser = subps.add_parser("update-docs", help=("Update the documentation"))
-        parser.add_argument(
-            "--docs-repo-dir",
-            required=True,
-            help=("root directory of the SecureDrop documentation repository"),
-        )
-        parser.set_defaults(func=self.update_docs)
-
-    def update_from_weblate(self, args: argparse.Namespace) -> None:
-        """
-        Pull in updated translations from the i18n repo.
-        """
-        self.ensure_i18n_remote(args)
-
-        # Check out *all* remote changes to the LOCALE_DIRs.
-        subprocess.check_call(
-            [
-                "git",
-                "checkout",
-                args.target,
-                "--",
-                *LOCALE_DIR.values(),
-            ],
-            cwd=args.root,
-        )
-
-        # Use the LOCALE_DIR corresponding to the "securedrop" component to
-        # determine which locales are present.
-        locale_dir = os.path.join(args.root, LOCALE_DIR["securedrop"])
-        locales = self.translated_locales(locale_dir)
-        if args.supported_languages:
-            codes = args.supported_languages.split(",")
-            locales = {code: locales[code] for code in locales if code in codes}
-
-        # Then iterate over all locales present and decide which to stage and commit.
-        for code, path in locales.items():
-            paths = []
-
-            def add(path: str) -> None:
-                """
-                Add the file to the git index.
-                """
-                subprocess.check_call(["git", "add", path], cwd=args.root)
-                paths.append(path)
-
-            # Any translated locale may have changes that need to be staged from the
-            # securedrop/securedrop component.
-            add(path)
-
-            # Only supported locales may have changes that need to be staged from the
-            # securedrop/desktop component, because the link between the two components
-            # is defined in I18N_CONF when a language is marked supported.
-            try:
-                info = self.supported_languages[code]
-                name = info["name"]
-                desktop_code = info["desktop"]
-                path = join(
-                    LOCALE_DIR["desktop"],
-                    f"{desktop_code}.po",
-                )
-                add(path)
-            except KeyError:
-                log.info(
-                    f"{code} has translations but is not marked as supported; "
-                    f"skipping desktop translation"
-                )
-                name = code
-
-            # Try to commit changes for this locale no matter what, even if it turns out to be a
-            # no-op.
-            self.commit_changes(args, code, name, paths)
-
-    def translators(
-        self, args: argparse.Namespace, path: str, since_commit: Optional[str]
-    ) -> Set[str]:
-        """
-        Return the set of people who've modified a file in Weblate.
-
-        Extracts all the authors of translation changes to the given
-        path since the given commit. Translation changes are
-        identified by the presence of "Translated using Weblate" in
-        the commit message.
-        """
-
-        log_command = ["git", "--no-pager", "-C", args.root, "log", "--format=%aN\x1e%s\x1e%H"]
-
-        if since_commit:
-            since = self.get_commit_timestamp(args.root, since_commit)
-            log_command.extend(["--since", since])
-
-        log_command.extend([args.target, "--", path])
-
-        # NB. We use an explicit str.split("\n") here because str.splitlines() splits on a
-        # set of characters that includes the \x1e "record separator" we pass to "git log
-        # --format" in log_command.  See #6648.
-        log_lines = subprocess.check_output(log_command, encoding="utf-8").strip().split("\n")
-        path_changes = [c.split("\x1e") for c in log_lines]
-        path_changes = [
-            c
-            for c in path_changes
-            if len(c) > 1 and c[2] != since_commit and self.translated_commit_re.match(c[1])
-        ]
-        log.debug("Path changes for %s: %s", path, path_changes)
-        translators = {c[0] for c in path_changes}
-        log.debug("Translators for %s: %s", path, translators)
-        return translators
-
-    def get_path_commits(self, root: str, path: str) -> List[str]:
-        """
-        Returns the list of commit hashes involving the path, most recent first.
-        """
-        cmd = ["git", "--no-pager", "log", "--format=%H", path]
-        return subprocess.check_output(cmd, encoding="utf-8", cwd=root).splitlines()
-
-    def translated_locales(self, path: str) -> Dict[str, str]:
-        """Return a dictionary of all locale directories present in `path`, where the keys
-        are the base (directory) names and the values are the full paths."""
-        p = Path(path)
-        return {x.name: str(x) for x in p.iterdir() if x.is_dir()}
-
-    def commit_changes(
-        self, args: argparse.Namespace, code: str, name: str, paths: List[str]
-    ) -> None:
-        """Check if any of the given paths have had changed staged.  If so, commit them."""
-        self.require_git_email_name(args.root)
-        authors: Set[str] = set()
-        cmd = ["git", "--no-pager", "diff", "--name-only", "--cached", *paths]
-        diffs = subprocess.check_output(cmd, cwd=args.root, encoding="utf-8")
-
-        # If nothing was changed, "git commit" will return nonzero as a no-op.
-        if len(diffs) == 0:
-            return
-
-        # for each modified file, find the last commit made by this
-        # function, then collect all the contributors since that
-        # commit, so they can be credited in this one. if no commit
-        # with the right message is found, just use the most recent
-        # commit that touched the file.
-        for path in paths:
-            path_commits = self.get_path_commits(args.root, path)
-            since_commit = None
-            for path_commit in path_commits:
-                commit_message = subprocess.check_output(
-                    ["git", "--no-pager", "show", path_commit],
-                    encoding="utf-8",
-                    cwd=args.root,
-                )
-                m = self.updated_commit_re.search(commit_message)
-                if m:
-                    since_commit = m.group(1)
-                    break
-            log.debug("Crediting translators of %s since %s", path, since_commit)
-            authors |= self.translators(args, path, since_commit)
-
-        authors_as_str = "\n  ".join(sorted(authors))
-
-        current = subprocess.check_output(
-            ["git", "rev-parse", args.target], cwd=args.root, encoding="utf-8"
-        )
-        message = textwrap.dedent(
-            """
-        l10n: updated {name} ({code})
-
-        contributors:
-          {authors}
-
-        updated from:
-          repo: {remote}
-          commit: {current}
-        """
-        ).format(remote=args.url, name=name, authors=authors_as_str, code=code, current=current)
-        subprocess.check_call(["git", "commit", "-m", message, *paths], cwd=args.root)
-        log.debug(f"Committing with this message: {message}")
-
-    def set_update_from_weblate_parser(self, subps: _SubParsersAction) -> None:
-        parser = subps.add_parser("update-from-weblate", help=("Import translations from weblate"))
-        root = join(dirname(realpath(__file__)), "..")
-        parser.add_argument(
-            "--root",
-            default=root,
-            help=("root of the SecureDrop git repository" " (default {})".format(root)),
-        )
-        url = "https://github.com/freedomofpress/securedrop-i18n"
-        parser.add_argument(
-            "--url", default=url, help=("URL of the weblate repository" " (default {})".format(url))
-        )
-        parser.add_argument(
-            "--target",
-            default="i18n/i18n",
-            help=(
-                "Commit on i18n branch at which to stop gathering translator contributions "
-                "(default: i18n/i18n)"
-            ),
-        )
-        parser.add_argument(
-            "--supported-languages", help="comma separated list of supported languages"
-        )
-        parser.set_defaults(func=self.update_from_weblate)
-
-    def set_list_locales_parser(self, subps: _SubParsersAction) -> None:
-        parser = subps.add_parser("list-locales", help="List supported locales")
-        parser.add_argument(
-            "--python",
-            action="store_true",
-            help=("Print the locales as a Python list suitable for config.py"),
-        )
-        parser.add_argument("--lines", action="store_true", help=("List one locale per line"))
-        parser.set_defaults(func=self.list_locales)
-
-    def list_locales(self, args: argparse.Namespace) -> None:
-        if args.lines:
-            for lang in sorted(list(self.supported_languages.keys()) + ["en_US"]):
-                print(lang)
-        elif args.python:
-            print(sorted(list(self.supported_languages.keys()) + ["en_US"]))
-        else:
-            print(" ".join(sorted(list(self.supported_languages.keys()) + ["en_US"])))
-
-    def set_list_translators_parser(self, subps: _SubParsersAction) -> None:
-        parser = subps.add_parser("list-translators", help=("List contributing translators"))
-        root = join(dirname(realpath(__file__)), "..")
-        parser.add_argument(
-            "--root",
-            default=root,
-            help=("root of the SecureDrop git repository" " (default {})".format(root)),
-        )
-        url = "https://github.com/freedomofpress/securedrop-i18n"
-        parser.add_argument(
-            "--url", default=url, help=("URL of the weblate repository" " (default {})".format(url))
-        )
-        parser.add_argument(
-            "--target",
-            default="i18n/i18n",
-            help=(
-                "Commit on i18n branch at which to stop gathering translator contributions "
-                "(default: i18n/i18n)"
-            ),
-        )
-        parser.add_argument(
-            "--since",
-            help=(
-                "Gather translator contributions from the time of this commit "
-                "(default: last release tag)"
-            ),
-        )
-        parser.add_argument(
-            "--all",
-            action="store_true",
-            help=(
-                "List everyone who's ever contributed, instead of just since the last "
-                "release or specified commit."
-            ),
-        )
-        parser.set_defaults(func=self.list_translators)
-
-    def get_last_release(self, root: str) -> str:
-        """
-        Returns the last release tag, e.g. 1.5.0.
-        """
-        tags = (
-            subprocess.check_output(["git", "-C", root, "tag", "--list"])
-            .decode("utf-8")
-            .splitlines()
-        )
-        release_tags = sorted([t.strip() for t in tags if self.release_tag_re.match(t)])
-        if not release_tags:
-            raise ValueError("Could not find a release tag!")
-        return release_tags[-1]
-
-    def get_commit_timestamp(self, root: str, commit: Optional[str]) -> str:
-        """
-        Returns the UNIX timestamp of the given commit.
-        """
-        cmd = ["git", "-C", root, "log", "-n", "1", "--pretty=format:%ct"]
-        if commit:
-            cmd.append(commit)
-
-        timestamp = subprocess.check_output(cmd)
-        return timestamp.decode("utf-8").strip()
-
-    def list_translators(self, args: argparse.Namespace) -> None:
-        self.ensure_i18n_remote(args)
-        app_template = "{}/{}/LC_MESSAGES/messages.po"
-        desktop_template = LOCALE_DIR["desktop"] + "/{}.po"
-        since = None
-        if args.all:
-            print("Listing all translators who have ever helped")
-        else:
-            since = args.since if args.since else self.get_last_release(args.root)
-            print(f"Listing translators who have helped since {since}")
-        for code, info in sorted(self.supported_languages.items()):
-            translators = set()
-            paths = [
-                app_template.format(LOCALE_DIR["securedrop"], code),
-                desktop_template.format(info["desktop"]),
-            ]
-            for path in paths:
-                try:
-                    t = self.translators(args, path, since)
-                    translators.update(t)
-                except Exception as e:
-                    print(f"Could not check git history of {path}: {e}", file=sys.stderr)
-            print(
-                "{} ({}):{}".format(
-                    code,
-                    info["name"],
-                    "\n  {}\n".format("\n  ".join(sorted(translators))) if translators else "\n",
-                )
-            )
-
-    def get_args(self) -> argparse.ArgumentParser:
-        parser = argparse.ArgumentParser(prog=__file__, description="i18n tool for SecureDrop.")
-        parser.set_defaults(func=lambda args: parser.print_help())
-
-        parser.add_argument("-v", "--verbose", action="store_true")
-        subps = parser.add_subparsers()
-
-        self.set_translate_messages_parser(subps)
-        self.set_translate_desktop_parser(subps)
-        self.set_update_docs_parser(subps)
-        self.set_update_from_weblate_parser(subps)
-        self.set_list_translators_parser(subps)
-        self.set_list_locales_parser(subps)
-
-        return parser
-
-    def setup_verbosity(self, args: argparse.Namespace) -> None:
-        if args.verbose:
-            logging.getLogger("sh.command").setLevel(logging.INFO)
-            log.setLevel(logging.DEBUG)
-        else:
-            log.setLevel(logging.INFO)
-
-    def main(self, argv: List[str]) -> int:
-        try:
-            args = self.get_args().parse_args(argv)
-            self.setup_verbosity(args)
-            return args.func(args)
-        except KeyboardInterrupt:
-            return signal.SIGINT
-
-
-def _remove_from_content_line_with_text(text: str, content: str) -> str:
-    # Split where the text is located; assume there is only one instance of the text
-    split_content = content.split(text)
-    assert len(split_content) == 2
-
-    # Remove the while line containing the text
-    content_before_line = split_content[0]
-    content_after_line = split_content[1].split("\n", maxsplit=1)[1]
-    return content_before_line + content_after_line
-
-
-if __name__ == "__main__":  # pragma: no cover
-    sys.exit(I18NTool().main(sys.argv[1:]))


Test patch:
diff --git a/securedrop/tests/test_i18n.py b/securedrop/tests/test_i18n.py
--- a/securedrop/tests/test_i18n.py
+++ b/securedrop/tests/test_i18n.py
@@ -21,7 +21,6 @@
 from typing import List
 
 import i18n
-import i18n_tool
 import journalist_app as journalist_app_module
 import pytest
 import source_app
@@ -247,22 +246,31 @@ def test_i18n():
 
     i18n_dir = Path(__file__).absolute().parent / "i18n"
     sources = [str(i18n_dir / "code.py"), str(i18n_dir / "template.html")]
-    i18n_tool.I18NTool().main(
+    pot = i18n_dir / "messages.pot"
+    subprocess.check_call(
         [
-            "--verbose",
-            "translate-messages",
+            "pybabel",
+            "extract",
             "--mapping",
             str(i18n_dir / "babel.cfg"),
-            "--translations-dir",
-            str(translation_dirs),
-            "--sources",
-            ",".join(sources),
-            "--extract-update",
+            "--output",
+            pot,
+            *sources,
         ]
     )
 
-    pot = translation_dirs / "messages.pot"
-    subprocess.check_call(["pybabel", "init", "-i", pot, "-d", translation_dirs, "-l", "en_US"])
+    subprocess.check_call(
+        [
+            "pybabel",
+            "init",
+            "--input-file",
+            pot,
+            "--output-dir",
+            translation_dirs,
+            "--locale",
+            "en_US",
+        ]
+    )
 
     for (locale, translated_msg) in (
         ("fr_FR", "code bonjour"),
@@ -271,7 +279,18 @@ def test_i18n():
         ("nb_NO", "code norwegian"),
         ("es_ES", "code spanish"),
     ):
-        subprocess.check_call(["pybabel", "init", "-i", pot, "-d", translation_dirs, "-l", locale])
+        subprocess.check_call(
+            [
+                "pybabel",
+                "init",
+                "--input-file",
+                pot,
+                "--output-dir",
+                translation_dirs,
+                "--locale",
+                locale,
+            ]
+        )
 
         # Populate the po file with a translation
         po_file = translation_dirs / locale / "LC_MESSAGES" / "messages.po"
@@ -281,15 +300,18 @@ def test_i18n():
             msgstr=translated_msg,
         )
 
-    i18n_tool.I18NTool().main(
-        [
-            "--verbose",
-            "translate-messages",
-            "--translations-dir",
-            str(translation_dirs),
-            "--compile",
-        ]
-    )
+        subprocess.check_call(
+            [
+                "pybabel",
+                "compile",
+                "--directory",
+                translation_dirs,
+                "--locale",
+                locale,
+                "--input-file",
+                po_file,
+            ]
+        )
 
     # Use our config (and not an app fixture) because the i18n module
     # grabs values at init time and we can't inject them later.
diff --git a/securedrop/tests/test_i18n_tool.py b/securedrop/tests/test_i18n_tool.py
deleted file mode 100644
--- a/securedrop/tests/test_i18n_tool.py
+++ /dev/null
@@ -1,442 +0,0 @@
-import os
-import shutil
-import signal
-import subprocess
-import time
-from os.path import abspath, dirname, exists, getmtime, join, realpath
-from pathlib import Path
-from unittest.mock import patch
-
-import i18n_tool
-import pytest
-from tests.test_i18n import set_msg_translation_in_po_file
-
-
-class TestI18NTool:
-    def setup(self):
-        self.dir = abspath(dirname(realpath(__file__)))
-
-    def test_main(self, tmpdir, caplog):
-        with pytest.raises(SystemExit):
-            i18n_tool.I18NTool().main(["--help"])
-
-        tool = i18n_tool.I18NTool()
-        with patch.object(tool, "setup_verbosity", side_effect=KeyboardInterrupt):
-            assert (
-                tool.main(["translate-messages", "--translations-dir", str(tmpdir)])
-                == signal.SIGINT
-            )
-
-    def test_translate_desktop_l10n(self, tmpdir):
-        in_files = {}
-        for what in ("source", "journalist"):
-            in_files[what] = join(str(tmpdir), what + ".desktop.in")
-            shutil.copy(join(self.dir, "i18n/" + what + ".desktop.in"), in_files[what])
-        i18n_tool.I18NTool().main(
-            [
-                "--verbose",
-                "translate-desktop",
-                "--translations-dir",
-                str(tmpdir),
-                "--sources",
-                in_files["source"],
-                "--extract-update",
-            ]
-        )
-        messages_file = join(str(tmpdir), "desktop.pot")
-        assert exists(messages_file)
-        with open(messages_file) as fobj:
-            pot = fobj.read()
-            assert "SecureDrop Source Interfaces" in pot
-        # pretend this happened a few seconds ago
-        few_seconds_ago = time.time() - 60
-        os.utime(messages_file, (few_seconds_ago, few_seconds_ago))
-
-        i18n_file = join(str(tmpdir), "source.desktop")
-
-        # Extract+update but do not compile
-        old_messages_mtime = getmtime(messages_file)
-        assert not exists(i18n_file)
-        i18n_tool.I18NTool().main(
-            [
-                "--verbose",
-                "translate-desktop",
-                "--translations-dir",
-                str(tmpdir),
-                "--sources",
-                ",".join(list(in_files.values())),
-                "--extract-update",
-            ]
-        )
-        assert not exists(i18n_file)
-        current_messages_mtime = getmtime(messages_file)
-        assert old_messages_mtime < current_messages_mtime
-
-        for locale, translation in [
-            ("fr_FR", "SOURCE FR"),
-            # Regression test for #4192; bug when adding Romanian as an accepted language
-            ("ro", "SOURCE RO"),
-        ]:
-            po_file = Path(tmpdir) / f"{locale}.po"
-            subprocess.check_call(
-                [
-                    "msginit",
-                    "--no-translator",
-                    "--locale",
-                    locale,
-                    "--output",
-                    po_file,
-                    "--input",
-                    messages_file,
-                ]
-            )
-            set_msg_translation_in_po_file(
-                po_file=po_file,
-                msgid_to_translate="SecureDrop Source Interfaces",
-                msgstr=translation,
-            )
-
-        # Compile but do not extract+update
-        old_messages_mtime = current_messages_mtime
-        i18n_tool.I18NTool().main(
-            [
-                "--verbose",
-                "translate-desktop",
-                "--translations-dir",
-                str(tmpdir),
-                "--sources",
-                ",".join(list(in_files.values()) + ["BOOM"]),
-                "--compile",
-            ]
-        )
-        assert old_messages_mtime == getmtime(messages_file)
-        with open(po_file) as fobj:
-            po = fobj.read()
-            assert "SecureDrop Source Interfaces" in po
-            assert "SecureDrop Journalist Interfaces" not in po
-        with open(i18n_file) as fobj:
-            i18n = fobj.read()
-            assert "SOURCE FR" in i18n
-
-    def test_translate_messages_l10n(self, tmpdir):
-        source = [
-            join(self.dir, "i18n/code.py"),
-            join(self.dir, "i18n/template.html"),
-        ]
-        args = [
-            "--verbose",
-            "translate-messages",
-            "--translations-dir",
-            str(tmpdir),
-            "--mapping",
-            join(self.dir, "i18n/babel.cfg"),
-            "--sources",
-            ",".join(source),
-            "--extract-update",
-            "--compile",
-        ]
-        i18n_tool.I18NTool().main(args)
-        messages_file = join(str(tmpdir), "messages.pot")
-        assert exists(messages_file)
-        with open(messages_file, "rb") as fobj:
-            pot = fobj.read()
-            assert b"code hello i18n" in pot
-            assert b"template hello i18n" in pot
-
-        locale = "en_US"
-        locale_dir = join(str(tmpdir), locale)
-        subprocess.check_call(
-            [
-                "pybabel",
-                "init",
-                "-i",
-                messages_file,
-                "-d",
-                str(tmpdir),
-                "-l",
-                locale,
-            ]
-        )
-
-        # Add a dummy translation
-        po_file = Path(locale_dir) / "LC_MESSAGES/messages.po"
-        for msgid in ["code hello i18n", "template hello i18n"]:
-            set_msg_translation_in_po_file(
-                po_file=po_file,
-                msgid_to_translate=msgid,
-                msgstr=msgid,
-            )
-
-        mo_file = join(locale_dir, "LC_MESSAGES/messages.mo")
-        assert not exists(mo_file)
-        i18n_tool.I18NTool().main(args)
-        assert exists(mo_file)
-        with open(mo_file, mode="rb") as fobj:
-            mo = fobj.read()
-            assert b"code hello i18n" in mo
-            assert b"template hello i18n" in mo
-
-    def test_translate_messages_compile_arg(self, tmpdir):
-        args = [
-            "--verbose",
-            "translate-messages",
-            "--translations-dir",
-            str(tmpdir),
-            "--mapping",
-            join(self.dir, "i18n/babel.cfg"),
-        ]
-        i18n_tool.I18NTool().main(
-            args
-            + [
-                "--sources",
-                join(self.dir, "i18n/code.py"),
-                "--extract-update",
-            ]
-        )
-        messages_file = join(str(tmpdir), "messages.pot")
-        assert exists(messages_file)
-        with open(messages_file) as fobj:
-            pot = fobj.read()
-            assert "code hello i18n" in pot
-
-        locale = "en_US"
-        locale_dir = join(str(tmpdir), locale)
-        po_file = join(locale_dir, "LC_MESSAGES/messages.po")
-        subprocess.check_call(
-            ["pybabel", "init", "-i", messages_file, "-d", str(tmpdir), "-l", locale]
-        )
-        assert exists(po_file)
-        # pretend this happened a few seconds ago
-        few_seconds_ago = time.time() - 60
-        os.utime(po_file, (few_seconds_ago, few_seconds_ago))
-
-        mo_file = join(locale_dir, "LC_MESSAGES/messages.mo")
-
-        #
-        # Extract+update but do not compile
-        #
-        old_po_mtime = getmtime(po_file)
-        assert not exists(mo_file)
-        i18n_tool.I18NTool().main(
-            args
-            + [
-                "--sources",
-                join(self.dir, "i18n/code.py"),
-                "--extract-update",
-            ]
-        )
-        assert not exists(mo_file)
-        current_po_mtime = getmtime(po_file)
-        assert old_po_mtime < current_po_mtime
-
-        #
-        # Translation would occur here - let's fake it
-        set_msg_translation_in_po_file(
-            po_file=Path(po_file),
-            msgid_to_translate="code hello i18n",
-            msgstr="code hello i18n",
-        )
-
-        #
-        # Compile but do not extract+update
-        #
-        source = [
-            join(self.dir, "i18n/code.py"),
-            join(self.dir, "i18n/template.html"),
-        ]
-        current_po_mtime = getmtime(po_file)
-        i18n_tool.I18NTool().main(
-            args
-            + [
-                "--sources",
-                ",".join(source),
-                "--compile",
-            ]
-        )
-        assert current_po_mtime == getmtime(po_file)
-        with open(mo_file, mode="rb") as fobj:
-            mo = fobj.read()
-            assert b"code hello i18n" in mo
-            assert b"template hello i18n" not in mo
-
-    def test_require_git_email_name(self, tmpdir):
-        k = {"cwd": str(tmpdir)}
-        subprocess.check_call(["git", "init"], **k)
-        with pytest.raises(Exception) as excinfo:
-            i18n_tool.I18NTool.require_git_email_name(str(tmpdir))
-        assert "please set name" in str(excinfo.value)
-
-        subprocess.check_call(["git", "config", "user.email", "you@example.com"], **k)
-        subprocess.check_call(["git", "config", "user.name", "Your Name"], **k)
-        assert i18n_tool.I18NTool.require_git_email_name(str(tmpdir))
-
-    def test_update_docs(self, tmpdir, caplog):
-        k = {"cwd": str(tmpdir)}
-        subprocess.check_call(["git", "init"], **k)
-        subprocess.check_call(["git", "config", "user.email", "you@example.com"], **k)
-        subprocess.check_call(["git", "config", "user.name", "Your Name"], **k)
-        os.makedirs(join(str(tmpdir), "docs/includes"))
-        subprocess.check_call(["touch", "docs/includes/l10n.txt"], **k)
-        subprocess.check_call(["git", "add", "docs/includes/l10n.txt"], **k)
-        subprocess.check_call(["git", "commit", "-m", "init"], **k)
-
-        i18n_tool.I18NTool().main(["--verbose", "update-docs", "--docs-repo-dir", str(tmpdir)])
-        assert "l10n.txt updated" in caplog.text
-        caplog.clear()
-        i18n_tool.I18NTool().main(["--verbose", "update-docs", "--docs-repo-dir", str(tmpdir)])
-        assert "l10n.txt already up to date" in caplog.text
-
-    def test_update_from_weblate(self, tmpdir, caplog):
-        d = str(tmpdir)
-        for repo in ("i18n", "securedrop"):
-            os.mkdir(join(d, repo))
-            k = {"cwd": join(d, repo)}
-            subprocess.check_call(["git", "init"], **k)
-            subprocess.check_call(["git", "config", "user.email", "you@example.com"], **k)
-            subprocess.check_call(["git", "config", "user.name", "Loïc Nordhøy"], **k)
-            subprocess.check_call(["touch", "README.md"], **k)
-            subprocess.check_call(["git", "add", "README.md"], **k)
-            subprocess.check_call(["git", "commit", "-m", "README", "README.md"], **k)
-        for o in os.listdir(join(self.dir, "i18n")):
-            f = join(self.dir, "i18n", o)
-            if os.path.isfile(f):
-                shutil.copyfile(f, join(d, "i18n", o))
-            else:
-                shutil.copytree(f, join(d, "i18n", o))
-        k = {"cwd": join(d, "i18n")}
-        subprocess.check_call(["git", "add", "securedrop", "install_files"], **k)
-        subprocess.check_call(["git", "commit", "-m", "init", "-a"], **k)
-        subprocess.check_call(["git", "checkout", "-b", "i18n", "master"], **k)
-
-        def r():
-            return "".join([str(record) for record in caplog.records])
-
-        #
-        # de_DE is not amount the supported languages, it is not taken
-        # into account despite the fact that it exists in weblate.
-        #
-        caplog.clear()
-        i18n_tool.I18NTool().main(
-            [
-                "--verbose",
-                "update-from-weblate",
-                "--root",
-                join(str(tmpdir), "securedrop"),
-                "--url",
-                join(str(tmpdir), "i18n"),
-                "--supported-languages",
-                "nl",
-            ]
-        )
-        assert "l10n: updated Dutch (nl)" in r()
-        assert "l10n: updated German (de_DE)" not in r()
-
-        #
-        # de_DE is added but there is no change in the nl translation
-        # therefore nothing is done for nl
-        #
-        caplog.clear()
-        i18n_tool.I18NTool().main(
-            [
-                "--verbose",
-                "update-from-weblate",
-                "--root",
-                join(str(tmpdir), "securedrop"),
-                "--url",
-                join(str(tmpdir), "i18n"),
-                "--supported-languages",
-                "nl,de_DE",
-            ]
-        )
-        assert "l10n: updated Dutch (nl)" not in r()
-        assert "l10n: updated German (de_DE)" in r()
-
-        #
-        # nothing new for nl or de_DE: nothing is done
-        #
-        caplog.clear()
-        i18n_tool.I18NTool().main(
-            [
-                "--verbose",
-                "update-from-weblate",
-                "--root",
-                join(str(tmpdir), "securedrop"),
-                "--url",
-                join(str(tmpdir), "i18n"),
-                "--supported-languages",
-                "nl,de_DE",
-            ]
-        )
-        assert "l10n: updated Dutch (nl)" not in r()
-        assert "l10n: updated German (de_DE)" not in r()
-        message = subprocess.check_output(
-            ["git", "--no-pager", "-C", "securedrop", "show"],
-            cwd=d,
-            encoding="utf-8",
-        )
-        assert "Loïc" in message
-
-        # an update is done to nl in weblate
-        i18n_dir = Path(d) / "i18n"
-        po_file = i18n_dir / "securedrop/translations/nl/LC_MESSAGES/messages.po"
-        content = po_file.read_text()
-        text_to_update = "inactiviteit"
-        assert text_to_update in content
-        updated_content = content.replace(text_to_update, "INACTIVITEIT")
-        po_file.write_text(updated_content)
-
-        k = {"cwd": join(d, "i18n")}
-        subprocess.check_call(["git", "add", str(po_file)], **k)
-        subprocess.check_call(["git", "config", "user.email", "somone@else.com"], **k)
-        subprocess.check_call(["git", "config", "user.name", "Someone Else"], **k)
-        subprocess.check_call(
-            ["git", "commit", "-m", "Translated using Weblate", str(po_file)], **k
-        )
-
-        k = {"cwd": join(d, "securedrop")}
-        subprocess.check_call(["git", "config", "user.email", "somone@else.com"], **k)
-        subprocess.check_call(["git", "config", "user.name", "Someone Else"], **k)
-
-        #
-        # the nl translation update from weblate is copied
-        # over.
-        #
-        caplog.clear()
-        i18n_tool.I18NTool().main(
-            [
-                "--verbose",
-                "update-from-weblate",
-                "--root",
-                join(str(tmpdir), "securedrop"),
-                "--url",
-                join(str(tmpdir), "i18n"),
-                "--supported-languages",
-                "nl,de_DE",
-            ]
-        )
-        assert "l10n: updated Dutch (nl)" in r()
-        assert "l10n: updated German (de_DE)" not in r()
-
-        # The translator is credited in Git history.
-        message = subprocess.check_output(
-            ["git", "--no-pager", "-C", "securedrop", "show"],
-            cwd=d,
-            encoding="utf-8",
-        )
-        assert "Someone Else" in message
-        assert "Loïc" not in message
-
-        # The "list-translators" command correctly reads the translator from Git history.
-        caplog.clear()
-        i18n_tool.I18NTool().main(
-            [
-                "--verbose",
-                "list-translators",
-                "--all",
-                "--root",
-                join(str(tmpdir), "securedrop"),
-                "--url",
-                join(str(tmpdir), "i18n"),
-            ]
-        )
-        assert "Someone Else" in caplog.text
diff --git a/securedrop/tests/test_template_filters.py b/securedrop/tests/test_template_filters.py
--- a/securedrop/tests/test_template_filters.py
+++ b/securedrop/tests/test_template_filters.py
@@ -2,7 +2,6 @@
 from datetime import datetime, timedelta
 from pathlib import Path
 
-import i18n_tool
 import journalist_app
 import source_app
 import template_filters
@@ -87,23 +86,24 @@ def do_test(create_app):
     test_config = create_config_for_i18n_test(supported_locales=["en_US", "fr_FR"])
 
     i18n_dir = Path(__file__).absolute().parent / "i18n"
-    i18n_tool.I18NTool().main(
+    pot = Path(test_config.TEMP_DIR) / "messages.pot"
+    subprocess.check_call(
         [
-            "--verbose",
-            "translate-messages",
+            "pybabel",
+            "extract",
             "--mapping",
             str(i18n_dir / "babel.cfg"),
-            "--translations-dir",
-            str(test_config.TEMP_DIR),
-            "--sources",
+            "--output",
+            pot,
             str(i18n_dir / "code.py"),
-            "--extract-update",
-            "--compile",
         ]
     )
 
+    # To be able to test template filters for a given language, its message
+    # catalog must exist, but it doesn't have to contain any actual
+    # translations.  So we can just initialize it based on the template created
+    # by "pybabel extract".
     for lang in ("en_US", "fr_FR"):
-        pot = Path(test_config.TEMP_DIR) / "messages.pot"
         subprocess.check_call(
             ["pybabel", "init", "-i", pot, "-d", test_config.TEMP_DIR, "-l", lang]
         )