Password page with the AD policy listed first, ordered service (throttle limit below the lockout threshold, verification, local hints, directory change, ended sessions, audit and confirmation mail in one transaction), plain-language errors, German text and tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N3t4WL7bd9bdHHNjEsxzqC
121 lines
4.6 KiB
Python
121 lines
4.6 KiB
Python
"""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)
|