Files
computer-playbook/tests/unit/python/cli/build/i18n/test_main.py
T
kevinveenbirkenbachandClaude Opus 5 4c98624e07 feat(i18n): gettext catalogs with LibreTranslate machine translation
Core and the documentation get gettext catalogs for all 184 ISO 639-1
languages listed in meta/languages.yml, 47 of which LibreTranslate can fill.

`make i18n-extract` merges the current sources into locale/<code>/
LC_MESSAGES/{core,docs}.po: role descriptions, category and menu titles and
the logout strings for the core domain, the Sphinx gettext output for the
docs domain. A changed source keeps its previous translation as a fuzzy
entry, and catalogs are written deterministically so an unchanged merge
leaves the files untouched.

`make i18n-translate domain=<core|docs> [languages=<codes>]` fills only the
empty and fuzzy entries with a short-lived LibreTranslate container (the
image web-svc-libretranslate pins) and removes it afterwards. It starts the
container, and loads models, only for languages that still have pending
entries. Code spans, placeholders, URLs, tags and dotted names such as
Infinito.Nexus are masked for the request and a translation that damages
one is discarded. An unreachable server stops the run instead of
discarding every remaining entry, so a rerun continues where it stopped;
machine translations carry the translator comment `libretranslate`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 18:22:03 +02:00

72 lines
2.3 KiB
Python

from __future__ import annotations
import contextlib
import importlib
import unittest
import unittest.mock as mock
from pathlib import Path
from tempfile import TemporaryDirectory
from utils.i18n.catalog import catalog_path, new_catalog, write_catalog
from utils.i18n.languages import LANGUAGES_FILE
cli = importlib.import_module("cli.build.i18n.__main__")
LANGUAGES = """\
en: {name: English, native: English, direction: ltr, libretranslate: true}
de: {name: German, native: Deutsch, direction: ltr, libretranslate: true}
fr: {name: French, native: Français, direction: ltr, libretranslate: true}
"""
def _catalog(root: Path, code: str, translation: str) -> None:
catalog = new_catalog("core", code)
catalog.add("Hello", translation, context="menu:x:title")
path = catalog_path(root, code, "core")
path.parent.mkdir(parents=True)
write_catalog(path, catalog)
class TestTranslate(unittest.TestCase):
def setUp(self) -> None:
self._tmp = TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.root = Path(self._tmp.name)
(self.root / LANGUAGES_FILE).parent.mkdir()
(self.root / LANGUAGES_FILE).write_text(LANGUAGES, encoding="utf-8")
_catalog(self.root, "de", "Hallo")
def _run(self) -> tuple[int, mock.Mock]:
started = mock.Mock(return_value=contextlib.nullcontext("http://lt"))
client = mock.Mock()
client.return_value.translate.side_effect = lambda texts, code: (
["Bonjour"] * len(texts)
)
with (
mock.patch.object(cli, "PROJECT_ROOT", self.root),
mock.patch.object(cli, "pinned_image", return_value="lt:1"),
mock.patch.object(cli, "container", started),
mock.patch.object(cli, "LibreTranslate", client),
):
return cli.translate("core", []), started
def test_only_languages_with_pending_entries_start_the_container(self) -> None:
_catalog(self.root, "fr", "")
status, started = self._run()
self.assertEqual(status, 0)
self.assertEqual(started.call_args.args[1], ["fr"])
def test_nothing_pending_starts_no_container(self) -> None:
_catalog(self.root, "fr", "Bonjour")
status, started = self._run()
self.assertEqual(status, 0)
started.assert_not_called()
if __name__ == "__main__":
unittest.main()