mirror of
https://github.com/qelectrotech/qelectrotech-source-mirror.git
synced 2026-10-10 14:14:18 +02:00
Mirror qet_en.ts instead of qet_fr.ts now that English is the source
#1379 added misc/i18n/mirror_source_language.py and an INSTALL.md rule to run it on lang/qet_fr.ts after update_translations, while French was the source language. Since #1390 the code is English and qet_fr.ts is an ordinary translation, so following the rule would copy English text into empty French entries. The repository test still checked qet_fr.ts and kept passing. The rule, the script's description and the repository test now name lang/qet_en.ts. The script refuses a file whose <TS> header names a language different from its sourcelanguage (qet_fr.ts says fr_FR / en), exits 2 and leaves it untouched; files without a sourcelanguage, like the fixtures, are accepted as before. The 6 entries master added since the conversion are mirrored, so qet_en.ts is complete again. Tests: 19 in misc/i18n/test_mirror_source_language.py. The repository test fails on master (6 entries to mirror); with the check_languages() call removed, the two refusal tests fail and a run on qet_fr.ts goes through. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,30 +18,35 @@
|
||||
mirror_source_language.py — give every source-language string its own
|
||||
translation in the source language's .ts file.
|
||||
|
||||
python3 misc/i18n/mirror_source_language.py lang/qet_fr.ts
|
||||
python3 misc/i18n/mirror_source_language.py --check lang/qet_fr.ts
|
||||
python3 misc/i18n/mirror_source_language.py lang/qet_en.ts
|
||||
python3 misc/i18n/mirror_source_language.py --check lang/qet_en.ts
|
||||
|
||||
WHY THIS EXISTS
|
||||
|
||||
The strings in the code are French, and they are both the translation
|
||||
key and the text the French UI shows. lang/qet_fr.ts was almost empty:
|
||||
an empty entry falls back to the code text at run time, so French never
|
||||
needed a translation. The price is that a French wording fix is a key
|
||||
change, which orphans the translation of that string in every other
|
||||
language file.
|
||||
The strings in the code are English (French until #1390), and they are
|
||||
both the translation key and the text the English UI shows. An empty
|
||||
entry in lang/qet_en.ts falls back to the code text at run time, so the
|
||||
source language never strictly needs a translation. The price is that an
|
||||
English wording fix in the code is a key change, which orphans the
|
||||
translation of that string in every other language file.
|
||||
|
||||
With every French string mirrored into qet_fr.ts, the French UI is
|
||||
served from qet_fr.qm like any other language, and French wording can
|
||||
be corrected in the .ts alone. The key in the code then only has to
|
||||
With every source string mirrored into its own .ts file, the source
|
||||
language is served from its .qm like any other language, and its wording
|
||||
can be corrected in the .ts alone. The key in the code then only has to
|
||||
stay stable.
|
||||
|
||||
Only the source language's own file may be mirrored: a run on any other
|
||||
file would copy English into it. The script therefore refuses a file
|
||||
whose <TS> header names a language different from its sourcelanguage
|
||||
(lang/qet_fr.ts says language="fr_FR" sourcelanguage="en").
|
||||
|
||||
WHAT IT DOES
|
||||
|
||||
For every message whose translation is empty, the source text is copied
|
||||
into the translation and the entry is finished. Nothing else changes:
|
||||
|
||||
- a translation with text is never touched, whether it is a French
|
||||
rendering of an English source, identical to the source, or marked
|
||||
rendering of a source in another language, identical to the source, or marked
|
||||
unfinished (a translator's work in progress), with one exception:
|
||||
an unfinished translation identical to its source is finished.
|
||||
That is what lupdate's same-text heuristic leaves behind when a new
|
||||
@@ -87,6 +92,39 @@ class SourceError(ValueError):
|
||||
"""A <source> that cannot be copied verbatim."""
|
||||
|
||||
|
||||
class LanguageError(ValueError):
|
||||
"""The file is a translation into another language, not a mirror."""
|
||||
|
||||
|
||||
TS_HEADER_RE = re.compile(r"<TS\b[^>]*>")
|
||||
ATTR_RE = r'\b{}="([^"]*)"'
|
||||
|
||||
|
||||
def _base_language(code: str) -> str:
|
||||
return code.replace("-", "_").split("_")[0].lower()
|
||||
|
||||
|
||||
def check_languages(text: str) -> None:
|
||||
"""Refuse a .ts whose language is not its source language.
|
||||
|
||||
A file without a sourcelanguage attribute is accepted: lupdate writes
|
||||
one only when it knows the source language, and the fixtures and old
|
||||
files have none.
|
||||
"""
|
||||
header = TS_HEADER_RE.search(text)
|
||||
if header is None:
|
||||
return
|
||||
lang = re.search(ATTR_RE.format("language"), header.group(0))
|
||||
source = re.search(ATTR_RE.format("sourcelanguage"), header.group(0))
|
||||
if lang is None or source is None:
|
||||
return
|
||||
if _base_language(lang.group(1)) != _base_language(source.group(1)):
|
||||
raise LanguageError(
|
||||
f'the file is a translation into "{lang.group(1)}" of '
|
||||
f'"{source.group(1)}" text; only the source language\'s own '
|
||||
"file can be mirrored")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Stats:
|
||||
mirrored_messages: int = 0
|
||||
@@ -195,6 +233,7 @@ def _mirror_message(match: re.Match, forms: int, stats: Stats) -> str:
|
||||
|
||||
def mirror_text(text: str, forms: int = 2) -> tuple[str, Stats]:
|
||||
"""Return the mirrored .ts text and what was done to it."""
|
||||
check_languages(text)
|
||||
stats = Stats()
|
||||
new = MESSAGE_RE.sub(lambda m: _mirror_message(m, forms, stats), text)
|
||||
return new, stats
|
||||
@@ -204,12 +243,12 @@ def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Copy each empty translation's source text into the "
|
||||
"translation of a source-language .ts file.")
|
||||
parser.add_argument("ts", type=Path, help="the .ts file, e.g. lang/qet_fr.ts")
|
||||
parser.add_argument("ts", type=Path, help="the .ts file, e.g. lang/qet_en.ts")
|
||||
parser.add_argument("--check", action="store_true",
|
||||
help="change nothing; exit 1 if a run would change the file")
|
||||
parser.add_argument("--forms", type=int, default=2,
|
||||
help="plural forms to write when a plural message has "
|
||||
"none (default 2, French)")
|
||||
"none (default 2, as in English and French)")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
with open(args.ts, encoding="utf-8", newline="") as f:
|
||||
@@ -217,7 +256,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
try:
|
||||
new, stats = mirror_text(text, args.forms)
|
||||
ET.fromstring(new.encode("utf-8"))
|
||||
except (SourceError, ET.ParseError) as e:
|
||||
except (SourceError, LanguageError, ET.ParseError) as e:
|
||||
print(f"{args.ts}: {e}", file=sys.stderr)
|
||||
return 2
|
||||
print(f"{args.ts}: {stats.summary()}")
|
||||
|
||||
@@ -10,7 +10,7 @@ byte for byte. Every message class the script distinguishes appears
|
||||
once in the pair, so a change in behaviour shows up as a diff in a
|
||||
known place.
|
||||
|
||||
The last test looks at the repository's own lang/qet_fr.ts and fails
|
||||
The last test looks at the repository's own lang/qet_en.ts and fails
|
||||
when it needs a mirror run: that is the maintenance rule from
|
||||
INSTALL.md, kept honest.
|
||||
"""
|
||||
@@ -33,6 +33,7 @@ import mirror_source_language as m # noqa: E402
|
||||
SCRIPT = HERE / "mirror_source_language.py"
|
||||
BEFORE = HERE / "fixtures" / "mirror_before.ts"
|
||||
AFTER = HERE / "fixtures" / "mirror_after.ts"
|
||||
REPO_EN = HERE.parent.parent / "lang" / "qet_en.ts"
|
||||
REPO_FR = HERE.parent.parent / "lang" / "qet_fr.ts"
|
||||
|
||||
|
||||
@@ -153,6 +154,27 @@ class MirrorText(unittest.TestCase):
|
||||
self.assertNotIn("unfinished", out)
|
||||
ET.fromstring(out)
|
||||
|
||||
def test_translation_into_another_language_is_refused(self):
|
||||
for header in ('<TS version="2.1" language="fr_FR" sourcelanguage="en">',
|
||||
'<TS language="de" sourcelanguage="en_US">'):
|
||||
text = (header + '<context><name>C</name><message>\n'
|
||||
' <source>Open</source>\n'
|
||||
' <translation></translation>\n'
|
||||
' </message></context></TS>')
|
||||
with self.assertRaises(m.LanguageError, msg=header):
|
||||
m.mirror_text(text)
|
||||
|
||||
def test_source_language_file_is_accepted(self):
|
||||
for header in ('<TS version="2.1" language="en_US" sourcelanguage="en">',
|
||||
'<TS version="2.1" language="fr_FR">'): # no sourcelanguage
|
||||
text = (header + '<context><name>C</name><message>\n'
|
||||
' <source>Open</source>\n'
|
||||
' <translation></translation>\n'
|
||||
' </message></context></TS>')
|
||||
out, stats = m.mirror_text(text)
|
||||
self.assertEqual(stats.mirrored_messages, 1, header)
|
||||
self.assertIn("<translation>Open</translation>", out)
|
||||
|
||||
def test_source_with_child_element_is_refused(self):
|
||||
text = ('<TS><context><name>C</name><message>\n'
|
||||
' <source>a<byte value="7"/>b</source>\n'
|
||||
@@ -198,12 +220,24 @@ class CommandLine(unittest.TestCase):
|
||||
self.assertIn("child element", r.stderr)
|
||||
self.assertEqual(read(tmp), bad)
|
||||
|
||||
@unittest.skipUnless(REPO_FR.is_file(), "lang/qet_fr.ts not found next to the tool")
|
||||
def test_repository_french_file_is_mirrored(self):
|
||||
"""The maintenance rule: run the mirror after update_translations."""
|
||||
r = self.run_script("--check", str(REPO_FR))
|
||||
@unittest.skipUnless(REPO_EN.is_file(), "lang/qet_en.ts not found next to the tool")
|
||||
def test_repository_source_language_file_is_mirrored(self):
|
||||
"""The maintenance rule: run the mirror after update_translations.
|
||||
|
||||
Until 2026-10-09 this checked lang/qet_fr.ts, which #1390 had turned
|
||||
into an ordinary translation; it passed while the rule was wrong."""
|
||||
r = self.run_script("--check", str(REPO_EN))
|
||||
self.assertEqual(r.returncode, 0,
|
||||
f"lang/qet_fr.ts needs a mirror run:\n{r.stdout}")
|
||||
f"lang/qet_en.ts needs a mirror run:\n{r.stdout}")
|
||||
|
||||
@unittest.skipUnless(REPO_FR.is_file(), "lang/qet_fr.ts not found next to the tool")
|
||||
def test_repository_translation_file_is_refused(self):
|
||||
"""A run on the French translation would copy English into it."""
|
||||
before = REPO_FR.read_bytes()
|
||||
r = self.run_script(str(REPO_FR))
|
||||
self.assertEqual(r.returncode, 2, r.stdout)
|
||||
self.assertIn("only the source language", r.stderr)
|
||||
self.assertEqual(REPO_FR.read_bytes(), before)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user