"""Every user-visible string has German text (AD-15); the informal catalogue only differs.""" import ast import re from pathlib import Path import polib import pytest PORTAL = Path(__file__).resolve().parent.parent LOCALE = PORTAL / "locale" TRANS_TAG = re.compile(r"""\{%\s*trans\s+(?:"([^"]+)"|'([^']+)')""") BLOCKTRANS = re.compile(r"\{%\s*blocktrans[^%]*%\}(.*?)\{%\s*endblocktrans\s*%\}", re.S) VARIABLE = re.compile(r"\{\{\s*(\w+)\s*\}\}") SINGLE = {"_", "gettext", "gettext_lazy"} PLURAL = {"ngettext", "ngettext_lazy"} def _call_name(node: ast.Call) -> str: func = node.func return func.id if isinstance(func, ast.Name) else getattr(func, "attr", "") def _strings(node: ast.Call, count: int) -> tuple[str, ...] | None: args = node.args[:count] if len(args) == count and all( isinstance(a, ast.Constant) and isinstance(a.value, str) for a in args ): return tuple(a.value for a in args) return None def messages_in_code() -> set[tuple[str, str | None]]: """(msgid, msgid_plural) for every translatable string in templates and Python code.""" found: set[tuple[str, str | None]] = set() templates = [*(PORTAL / "templates").rglob("*.html"), *(PORTAL / "templates").rglob("*.txt")] for template in templates: text = template.read_text(encoding="utf-8") found.update((a or b, None) for a, b in TRANS_TAG.findall(text)) found.update( (VARIABLE.sub(r"%(\1)s", body).strip(), None) for body in BLOCKTRANS.findall(text) ) for source in PORTAL.rglob("*.py"): if "tests" in source.parts or "migrations" in source.parts: continue for node in ast.walk(ast.parse(source.read_text(encoding="utf-8"))): if not isinstance(node, ast.Call): continue name = _call_name(node) if name in SINGLE and (strings := _strings(node, 1)): found.add((strings[0], None)) elif name in PLURAL and (strings := _strings(node, 2)): found.add((strings[0], strings[1])) return found def msgids_in_code() -> set[str]: return {msgid for msgid, _ in messages_in_code()} def catalogue(name: str) -> polib.POFile: return polib.pofile(str(LOCALE / name / "LC_MESSAGES" / "django.po")) def texts(entry: polib.POEntry) -> list[str]: """Every translated text of an entry: one, or one per plural form.""" return list(entry.msgstr_plural.values()) if entry.msgid_plural else [entry.msgstr] def test_the_scan_finds_the_strings(): messages = messages_in_code() assert {"Sign out", "Home", "Hello, %(name)s", "Audit log"} <= msgids_in_code() assert ("At least %(n)s character", "At least %(n)s characters") in messages def test_every_string_in_the_code_has_german_text(): german = {e.msgid: e for e in catalogue("de")} missing = sorted(msgids_in_code() - german.keys()) assert not missing, f"no German entry for: {missing}" for entry in german.values(): assert all(t.strip() for t in texts(entry)), f"empty German text for {entry.msgid!r}" assert "fuzzy" not in entry.flags, f"fuzzy entry {entry.msgid!r}" def test_plural_strings_are_plural_entries_with_both_forms(): german = {e.msgid: e for e in catalogue("de")} for msgid, plural in messages_in_code(): if plural: entry = german[msgid] assert entry.msgid_plural == plural and len(entry.msgstr_plural) == 2, msgid def test_the_german_catalogue_has_no_stale_entries(): stale = sorted({e.msgid for e in catalogue("de")} - msgids_in_code()) assert not stale, f"entries no longer used: {stale}" def test_the_informal_catalogue_only_holds_differences_to_known_strings(): german = {e.msgid: texts(e) for e in catalogue("de")} for entry in catalogue("de_DU"): assert entry.msgid in german, f"unknown string {entry.msgid!r}" assert all(t.strip() for t in texts(entry)) and "fuzzy" not in entry.flags assert texts(entry) != german[entry.msgid], f"{entry.msgid!r} does not differ" @pytest.mark.parametrize("name", ["de", "de_DU"]) def test_placeholders_survive_translation(name): pattern = re.compile(r"%\(\w+\)s") for entry in catalogue(name): wanted = set(pattern.findall(entry.msgid)) for text in texts(entry): assert set(pattern.findall(text)) == wanted, entry.msgid def test_the_formal_text_does_not_use_du_and_the_informal_does_not_use_sie(): formal = " ".join(t for e in catalogue("de") for t in texts(e)) assert not re.search(r"\b(du|dir|dich|dein|deine)\b", formal, re.I) informal = " ".join(t for e in catalogue("de_DU") for t in texts(e)) assert not re.search(r"\b(Sie|Ihnen|Ihr|Ihre)\b", informal)