mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-08 11:46:35 +00:00
fix(i18n): keep role names and product titles out of the translator
Nothing stopped a machine translation from rewriting the identifiers the documentation is about. Measured over the catalogs, 4836 of 25166 role name mentions came back altered and 9131 of 33337 title mentions, among them `(Unbound, etc.)` turning into `(Ungebunden, etc.)` for a DNS resolver and `Snipe-IT` losing its non-breaking hyphen to an ordinary one. Emoji went the same way. The names come from the repository itself: the role directories, the first heading of each README and galaxy_info.name. A hyphenated role name is never an English phrase and holds wherever it appears. A title is protected only inside a sentence, because at a sentence start every word is capitalised and the capital stops telling a product from a common noun: the heading `Documentation` is meant to be translated, `Keycloak` in the middle of a clause is not. A dictionary cannot make that call either, since `Unbound` and `Confluence` are English words and product names at once. Masking and comparing had to be separated. German capitalises every noun and compounds a name with what it qualifies, so `shell extensions` legitimately becomes `Shell-Erweiterungen`, which a set comparison reads as a surplus span and a word boundary ending at the hyphen reads as a loss. Names are therefore masked out of the request but checked for presence rather than counted, and protected_spans keeps the equality semantics it had for the structural rules. This reports 21352 entries whose translation dropped a name or an emoji. The finding is correct and the lints that surface it stay red until `make i18n-prune` empties them for the next run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
5c02ed6602
commit
2aa7325eca
4 files changed
+314
-7
No files matched your search
@@ -0,0 +1,130 @@
|
||||
"""Unit tests for :mod:`utils.i18n.names` and the span merge that consumes it.
|
||||
|
||||
The cases use names the repository really ships, so a renamed role shows up here
|
||||
rather than in a silently weakened protection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from itertools import pairwise
|
||||
|
||||
from utils.i18n.names import spans
|
||||
from utils.i18n.placeholders import (
|
||||
has_words,
|
||||
matches,
|
||||
missing_names,
|
||||
protected_spans,
|
||||
)
|
||||
|
||||
|
||||
def _protected(text: str) -> set[str]:
|
||||
return {text[start:end] for start, end in matches(text)}
|
||||
|
||||
|
||||
class TestRoleNames(unittest.TestCase):
|
||||
def test_a_role_name_is_protected_mid_sentence(self) -> None:
|
||||
self.assertIn("web-app-keycloak", _protected("Deploy web-app-keycloak first"))
|
||||
|
||||
def test_a_role_name_is_protected_at_the_start(self) -> None:
|
||||
self.assertIn("web-app-keycloak", _protected("web-app-keycloak issues tokens"))
|
||||
|
||||
def test_a_longer_identifier_is_not_cut_short(self) -> None:
|
||||
self.assertNotIn("web-app", _protected("Deploy web-app-keycloak first"))
|
||||
|
||||
def test_ordinary_hyphenated_english_stays_translatable(self) -> None:
|
||||
self.assertNotIn("self-hosted", _protected("A self-hosted open-source service"))
|
||||
|
||||
|
||||
class TestTitles(unittest.TestCase):
|
||||
def test_a_title_inside_a_sentence_is_protected(self) -> None:
|
||||
self.assertIn("Keycloak", _protected("The realm Keycloak issues tokens for"))
|
||||
|
||||
def test_a_title_that_is_also_an_adjective_is_protected_inside_a_sentence(
|
||||
self,
|
||||
) -> None:
|
||||
self.assertIn("Unbound", _protected("DNS resolver engines (Unbound, etc.)"))
|
||||
|
||||
def test_a_heading_stays_translatable(self) -> None:
|
||||
self.assertNotIn("Documentation", _protected("Documentation and Wiki Apps"))
|
||||
|
||||
def test_a_title_opening_a_sentence_stays_translatable(self) -> None:
|
||||
self.assertNotIn("Keycloak", _protected("Keycloak issues the token"))
|
||||
|
||||
def test_a_lowercase_reading_stays_translatable(self) -> None:
|
||||
self.assertNotIn("shell", _protected("bundling caffeine, shell extensions"))
|
||||
|
||||
def test_a_lowercase_common_noun_stays_translatable(self) -> None:
|
||||
self.assertNotIn(
|
||||
"documentation", _protected("including documentation and help")
|
||||
)
|
||||
|
||||
|
||||
class TestExtraSpans(unittest.TestCase):
|
||||
def test_an_emoji_is_protected(self) -> None:
|
||||
self.assertIn("🌐", _protected("ActivityPub Support 🌐 Seamlessly integrate"))
|
||||
|
||||
def test_an_email_address_is_protected(self) -> None:
|
||||
self.assertIn("kevin@veen.world", _protected("Contact kevin@veen.world today"))
|
||||
|
||||
|
||||
class TestMerge(unittest.TestCase):
|
||||
def test_spans_never_overlap(self) -> None:
|
||||
text = "Deploy `web-app-keycloak` next to Keycloak and Nextcloud 🌐"
|
||||
offsets = matches(text)
|
||||
|
||||
self.assertEqual(offsets, sorted(offsets))
|
||||
for (_, end), (start, _) in pairwise(offsets):
|
||||
self.assertLessEqual(end, start)
|
||||
|
||||
def test_a_backticked_name_is_claimed_once(self) -> None:
|
||||
text = "see `web-app-keycloak` for details"
|
||||
|
||||
self.assertIn("`web-app-keycloak`", _protected(text))
|
||||
|
||||
def test_a_name_stays_out_of_the_comparison_set(self) -> None:
|
||||
self.assertNotIn("Keycloak", protected_spans("Keycloak issues the token"))
|
||||
|
||||
|
||||
class TestMissingNames(unittest.TestCase):
|
||||
def test_a_dropped_name_is_reported(self) -> None:
|
||||
lost = missing_names(
|
||||
"DNS resolver engines (Unbound, etc.)", "DNS-Engines (Ungebunden, usw.)"
|
||||
)
|
||||
|
||||
self.assertEqual(lost, {"Unbound"})
|
||||
|
||||
def test_a_kept_name_is_not_reported(self) -> None:
|
||||
lost = missing_names(
|
||||
"DNS resolver engines (Unbound, etc.)", "DNS-Engines (Unbound, usw.)"
|
||||
)
|
||||
|
||||
self.assertEqual(lost, set())
|
||||
|
||||
def test_a_german_compound_still_carries_the_name(self) -> None:
|
||||
lost = missing_names(
|
||||
"one Redis user per consumer", "ein Redis-Benutzer je Nutzer"
|
||||
)
|
||||
|
||||
self.assertEqual(lost, set())
|
||||
|
||||
def test_a_capital_the_source_did_not_have_is_not_a_loss(self) -> None:
|
||||
lost = missing_names("bundling shell extensions", "bündelt Shell-Erweiterungen")
|
||||
|
||||
self.assertEqual(lost, set())
|
||||
|
||||
def test_a_message_of_only_names_holds_no_words(self) -> None:
|
||||
self.assertFalse(has_words("web-app-keycloak"))
|
||||
|
||||
def test_prose_still_holds_words(self) -> None:
|
||||
self.assertTrue(has_words("Deploy it behind the proxy"))
|
||||
|
||||
def test_spans_are_offsets_into_the_text(self) -> None:
|
||||
text = "Deploy web-app-keycloak now"
|
||||
start, end = spans(text)[0]
|
||||
|
||||
self.assertEqual(text[start:end], "web-app-keycloak")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,127 @@
|
||||
"""The role names and product titles a translation must hand back unchanged.
|
||||
|
||||
A role directory name is never an English phrase, so it is protected wherever it
|
||||
appears. A title is a single word often enough that the two readings collide:
|
||||
``Unbound`` is a DNS resolver and an adjective, ``Shell`` a role and a common
|
||||
noun. Measured over the catalogs, the capital separates them: of 47932
|
||||
capitalised occurrences the translator altered 22 percent, among them
|
||||
``(Unbound, etc.)`` turning into ``(Ungebunden, etc.)``, while the 40698
|
||||
lowercase ones it altered in 40 percent of cases and those readings were the
|
||||
common noun the text meant (``shell extensions``, ``user-driven documentation``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from utils.cache.files import PROJECT_ROOT
|
||||
from utils.roles.mapping import ROLE_FILE_META_MAIN
|
||||
|
||||
ROLES_DIR = "roles"
|
||||
README = "README.md"
|
||||
SENTENCE_END = re.compile(r"[.!?:;\n]\s*[*_`\"'(\[]*\s*$")
|
||||
OPENERS = " *_`\"'(["
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _sources(root: str) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
||||
"""Return the role directory names and the titles they declare.
|
||||
|
||||
Args:
|
||||
root: repository root, as a string so the cache key stays hashable.
|
||||
"""
|
||||
roles, titles = [], set()
|
||||
for path in sorted(Path(root, ROLES_DIR).iterdir()):
|
||||
if not path.is_dir():
|
||||
continue
|
||||
roles.append(path.name)
|
||||
readme = path / README
|
||||
if readme.is_file():
|
||||
for line in readme.read_text(encoding="utf-8").splitlines():
|
||||
if line.startswith("# "):
|
||||
titles.add(line[2:].strip())
|
||||
break
|
||||
meta = path / ROLE_FILE_META_MAIN
|
||||
if meta.is_file():
|
||||
declared = yaml.safe_load(meta.read_text(encoding="utf-8")) or {}
|
||||
name = (declared.get("galaxy_info") or {}).get("name")
|
||||
if name:
|
||||
titles.add(str(name).strip())
|
||||
return tuple(roles), tuple(titles)
|
||||
|
||||
|
||||
def _alternation(names) -> str:
|
||||
"""Return a regex matching any of ``names``, longest first.
|
||||
|
||||
The boundaries end at a word character, not at a hyphen: German compounds a
|
||||
name with the noun it qualifies, so ``Redis-Benutzer`` and ``API-URL`` carry
|
||||
the name intact and must not read as a loss. Matching the longest name first
|
||||
keeps ``web-app-keycloak`` from being served by a shorter sibling.
|
||||
|
||||
Args:
|
||||
names: the literals to match.
|
||||
"""
|
||||
ordered = sorted(names, key=len, reverse=True)
|
||||
return r"(?<!\w)(?:" + "|".join(re.escape(n) for n in ordered) + r")(?!\w)"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def patterns(
|
||||
root: str = str(PROJECT_ROOT),
|
||||
) -> tuple[re.Pattern | None, re.Pattern | None]:
|
||||
"""Return the unconditional and the capitalisation-gated name patterns.
|
||||
|
||||
Args:
|
||||
root: repository root.
|
||||
|
||||
Returns:
|
||||
The pattern for hyphenated role names, which hold wherever they appear,
|
||||
and the one for titles, which holds only on a capitalised occurrence.
|
||||
"""
|
||||
roles, titles = _sources(root)
|
||||
hyphenated = [name for name in roles if "-" in name]
|
||||
return (
|
||||
re.compile(_alternation(hyphenated)) if hyphenated else None,
|
||||
re.compile(_alternation(titles)) if titles else None,
|
||||
)
|
||||
|
||||
|
||||
def reads_as_a_name(text: str, start: int) -> bool:
|
||||
"""Return whether the title at ``start`` is a product name, not a noun.
|
||||
|
||||
The capital carries the distinction only away from a sentence start, where
|
||||
every word is capitalised anyway: ``(Unbound, etc.)`` names a DNS resolver,
|
||||
while the heading ``Documentation`` is the common noun the translator is
|
||||
meant to render.
|
||||
|
||||
Args:
|
||||
text: the message the occurrence sits in.
|
||||
start: offset of the occurrence.
|
||||
"""
|
||||
if not text[start].isupper():
|
||||
return False
|
||||
before = text[:start].rstrip(OPENERS)
|
||||
return bool(before) and not SENTENCE_END.search(before)
|
||||
|
||||
|
||||
def spans(text: str) -> list[tuple[int, int]]:
|
||||
"""Return the offsets of every name ``text`` carries.
|
||||
|
||||
Args:
|
||||
text: a source message.
|
||||
"""
|
||||
role_pattern, title_pattern = patterns()
|
||||
found = []
|
||||
if role_pattern:
|
||||
found += [(m.start(), m.end()) for m in role_pattern.finditer(text)]
|
||||
if title_pattern:
|
||||
found += [
|
||||
(m.start(), m.end())
|
||||
for m in title_pattern.finditer(text)
|
||||
if reads_as_a_name(text, m.start())
|
||||
]
|
||||
return found
|
||||
@@ -7,6 +7,8 @@ import re
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
|
||||
from utils.i18n import names
|
||||
|
||||
PRINTF = r"%\([A-Za-z_]\w*\)[sdif]|%[sdif]"
|
||||
PLACEHOLDER = re.compile(PRINTF)
|
||||
|
||||
@@ -42,6 +44,37 @@ def carries_placeholder(message_id: str | tuple[str, ...]) -> bool:
|
||||
return any(PLACEHOLDER.search(form) for form in forms)
|
||||
|
||||
|
||||
EXTRA = re.compile(
|
||||
r"[\U0001F300-\U0001FAFF☀-➿️]"
|
||||
r"|[\w.+-]+@[\w-]+\.[\w.]+"
|
||||
)
|
||||
|
||||
|
||||
def matches(text: str, with_names: bool = True) -> list[tuple[int, int]]:
|
||||
"""Return the non-overlapping offsets every protection rule claims.
|
||||
|
||||
Args:
|
||||
text: a source message or its translation.
|
||||
with_names: whether role names and titles take part. They do when a
|
||||
request is masked, so the translator never sees them, and they do
|
||||
not when two texts are compared: German capitalises every noun, so
|
||||
the lowercase ``shell extensions`` becomes ``Shell-Erweiterungen``
|
||||
and a set comparison would read the capital as a surplus span.
|
||||
"""
|
||||
found = [(m.start(), m.end()) for m in PROTECTED.finditer(text)]
|
||||
found += [(m.start(), m.end()) for m in EXTRA.finditer(text)]
|
||||
if with_names:
|
||||
found += names.spans(text)
|
||||
kept: list[tuple[int, int]] = []
|
||||
reach = 0
|
||||
for start, end in sorted(found):
|
||||
if start < reach:
|
||||
continue
|
||||
kept.append((start, end))
|
||||
reach = end
|
||||
return kept
|
||||
|
||||
|
||||
MARKUP = '[]`*{}()"'
|
||||
EMPHASIS = re.compile(r"(\*\*)[ \t]*([^\s]|[^\s].*?[^\s])[ \t]*\1", re.DOTALL)
|
||||
|
||||
@@ -100,7 +133,18 @@ def protected_spans(text: str) -> Counter:
|
||||
Args:
|
||||
text: a source message or its translation.
|
||||
"""
|
||||
return Counter(PROTECTED.findall(text))
|
||||
return Counter(text[start:end] for start, end in matches(text, with_names=False))
|
||||
|
||||
|
||||
def missing_names(source: str, translation: str) -> set[str]:
|
||||
"""Return the names ``source`` carries that ``translation`` dropped.
|
||||
|
||||
Args:
|
||||
source: the source message.
|
||||
translation: what came back for it.
|
||||
"""
|
||||
carried = {source[start:end] for start, end in names.spans(source)}
|
||||
return {name for name in carried if name not in translation}
|
||||
|
||||
|
||||
def has_words(text: str) -> bool:
|
||||
@@ -109,7 +153,12 @@ def has_words(text: str) -> bool:
|
||||
Args:
|
||||
text: a source message.
|
||||
"""
|
||||
return any(character.isalpha() for character in PROTECTED.sub("", text))
|
||||
plain, position = [], 0
|
||||
for start, end in matches(text):
|
||||
plain.append(text[position:start])
|
||||
position = end
|
||||
plain.append(text[position:])
|
||||
return any(character.isalpha() for character in "".join(plain))
|
||||
|
||||
|
||||
def mask(text: str) -> Masked:
|
||||
@@ -121,11 +170,11 @@ def mask(text: str) -> Masked:
|
||||
spans: list[str] = []
|
||||
parts: list[str] = []
|
||||
position = 0
|
||||
for match in PROTECTED.finditer(text):
|
||||
parts.append(html.escape(text[position : match.start()], quote=False))
|
||||
for start, end in matches(text):
|
||||
parts.append(html.escape(text[position:start], quote=False))
|
||||
parts.append(f'<x id="{len(spans)}"></x>')
|
||||
spans.append(match.group(0))
|
||||
position = match.end()
|
||||
spans.append(text[start:end])
|
||||
position = end
|
||||
parts.append(html.escape(text[position:], quote=False))
|
||||
return Masked("".join(parts), tuple(spans))
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from collections import Counter
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from utils.i18n.catalog import MACHINE_TRANSLATION
|
||||
from utils.i18n.placeholders import protected_spans
|
||||
from utils.i18n.placeholders import missing_names, protected_spans
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from babel.messages.catalog import Catalog, Message
|
||||
@@ -58,6 +58,7 @@ def damaged(catalog: Catalog) -> list[Message]:
|
||||
and message.string
|
||||
and (
|
||||
protected_spans(str(message.string)) != protected_spans(message.id)
|
||||
or missing_names(message.id, str(message.string))
|
||||
or structure(str(message.string)) != structure(message.id)
|
||||
)
|
||||
]
|
||||
|
||||
Reference in new issue
Block a user