i18n phase 3: Italian catalog
- babel.cfg and the catalog sources: bal/locale/bal.pot (424 texts) and
bal/locale/it_IT/LC_MESSAGES/bal.po, fully translated (43 entries taken
from Electrum's it_IT catalog, the rest following the owner's glossary
and review: "locktime" kept in English, "transazione senza
Will-Executor" for the backup transaction).
- build_zip.py compiles each bal.po into bal.mo inside the zip (Babel
required); .po/.pot are not shipped and *.mo is ignored by git.
- tests/test_translations.py: catalogs compile, {} fields and $tokens
match, no address/e-mail/URL added by a translation (patterns from
electrum-locale).
- AGENTS.md: how to update the catalogs or add a language.
See CHANGELOG entry 60 and PLAN_I18N.md.
This commit is contained in:
135
tests/test_translations.py
Normal file
135
tests/test_translations.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""Safety checks on BAL's translation catalogs (PLAN_I18N.md, section 4.4).
|
||||
|
||||
Runs on every ``bal/locale/<lang>/LC_MESSAGES/bal.po`` (the sources that
|
||||
build_zip.py compiles into the zip). A translation is shown to the user as
|
||||
if BAL wrote it, so a wrong or malicious one must fail here, before a zip is
|
||||
built:
|
||||
|
||||
* the catalog must parse and compile;
|
||||
* the ``{}`` replacement fields must match the English text (the same rule
|
||||
bal.i18n applies at run time), and so must the ``$tokens`` of the calendar
|
||||
texts (``$wallet_name``, ``$heirs_complete``), which Electrum does not check;
|
||||
* no Bitcoin address, e-mail address, URL or long letters-and-digits word may
|
||||
appear in a translation unless the English text has the same one: the
|
||||
patterns are copied from Electrum's ``electrum-locale/update.py`` (MIT
|
||||
licence, Copyright (C) The Electrum developers), which rejects translations
|
||||
that try to slip in an address or a link.
|
||||
|
||||
Run standalone (``python3 tests/test_translations.py``, prints a per-language
|
||||
summary) or with pytest. Needs Babel, like build_zip.py.
|
||||
"""
|
||||
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from babel.messages.mofile import write_mo # noqa: E402
|
||||
from babel.messages.pofile import read_po # noqa: E402
|
||||
|
||||
from bal.i18n import keeps_format_fields # noqa: E402
|
||||
|
||||
LOCALE_DIR = Path(__file__).resolve().parent.parent / "bal" / "locale"
|
||||
|
||||
# From electrum-locale/update.py (see the module docstring).
|
||||
SUSPICIOUS = {
|
||||
"Bitcoin address": re.compile("([13]|bc1)[a-zA-Z0-9]{30,}"),
|
||||
"e-mail address": re.compile(r"[\w.+-]+@[\w-]+\.[\w.-]+"),
|
||||
"URL": re.compile(r"\S+\.\S*\w+\S*/\S+"),
|
||||
"URL scheme": re.compile(r"http(s){0,1}://"),
|
||||
"letters-and-digits word": re.compile(
|
||||
r"(?a)(?=\w{16,})((\w*[a-zA-Z]\w*[0-9]\w*)|(\w*[0-9]\w*[a-zA-Z]\w*))"
|
||||
),
|
||||
}
|
||||
TOKEN = re.compile(r"\$\w+")
|
||||
|
||||
|
||||
def _catalogs():
|
||||
return sorted(LOCALE_DIR.glob("*/LC_MESSAGES/bal.po"))
|
||||
|
||||
|
||||
def _messages(po_path):
|
||||
"""Yield the translated, non-fuzzy (msgid, msgstr) pairs of a catalog."""
|
||||
with open(po_path, "rb") as f:
|
||||
catalog = read_po(f)
|
||||
for m in catalog:
|
||||
if m.id and isinstance(m.id, str) and m.string and not m.fuzzy:
|
||||
yield m.id, m.string
|
||||
|
||||
|
||||
def check_catalog(po_path):
|
||||
"""Return a list of problems found in one catalog (empty = fine)."""
|
||||
problems = []
|
||||
for msgid, msgstr in _messages(po_path):
|
||||
if not keeps_format_fields(msgid, msgstr):
|
||||
problems.append("{} fields differ: {!r}".format("{}", msgid))
|
||||
if sorted(TOKEN.findall(msgid)) != sorted(TOKEN.findall(msgstr)):
|
||||
problems.append("$tokens differ: {!r}".format(msgid))
|
||||
for name, regex in SUSPICIOUS.items():
|
||||
for match in regex.finditer(msgstr):
|
||||
if match.group(0) not in msgid:
|
||||
problems.append(
|
||||
"{} {!r} not in the English text: {!r}".format(
|
||||
name, match.group(0), msgid
|
||||
)
|
||||
)
|
||||
return problems
|
||||
|
||||
|
||||
def summary(po_path):
|
||||
"""Return (translated, untranslated, fuzzy) counts of a catalog."""
|
||||
with open(po_path, "rb") as f:
|
||||
catalog = read_po(f)
|
||||
msgs = [m for m in catalog if m.id]
|
||||
fuzzy = sum(1 for m in msgs if m.fuzzy)
|
||||
translated = sum(1 for m in msgs if m.string and not m.fuzzy)
|
||||
return translated, len(msgs) - translated - fuzzy, fuzzy
|
||||
|
||||
|
||||
def test_there_is_an_italian_catalog():
|
||||
assert LOCALE_DIR / "it_IT" / "LC_MESSAGES" / "bal.po" in _catalogs()
|
||||
|
||||
|
||||
def test_catalogs_compile():
|
||||
for po in _catalogs():
|
||||
with open(po, "rb") as f:
|
||||
catalog = read_po(f)
|
||||
buf = io.BytesIO()
|
||||
write_mo(buf, catalog)
|
||||
assert buf.getvalue(), po
|
||||
|
||||
|
||||
def test_translations_are_safe():
|
||||
problems = [
|
||||
"{}: {}".format(po.parent.parent.name, p)
|
||||
for po in _catalogs()
|
||||
for p in check_catalog(po)
|
||||
]
|
||||
assert not problems, "\n".join(problems)
|
||||
|
||||
|
||||
def test_suspicious_patterns_are_detected():
|
||||
# A translation that adds an address or a link must be caught.
|
||||
regexes = SUSPICIOUS.values()
|
||||
assert any(r.search("invia a bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq") for r in regexes)
|
||||
assert any(r.search("scarica da https://example.com") for r in regexes)
|
||||
assert any(r.search("scrivi a truffa@example.com") for r in regexes)
|
||||
assert not any(r.search("Firma il testamento") for r in regexes)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_there_is_an_italian_catalog()
|
||||
test_catalogs_compile()
|
||||
test_suspicious_patterns_are_detected()
|
||||
for po in _catalogs():
|
||||
translated, untranslated, fuzzy = summary(po)
|
||||
print(
|
||||
"{}: {} translated, {} untranslated, {} fuzzy".format(
|
||||
po.parent.parent.name, translated, untranslated, fuzzy
|
||||
)
|
||||
)
|
||||
test_translations_are_safe()
|
||||
print("[OK] translation catalogs are safe")
|
||||
Reference in New Issue
Block a user