#!/usr/bin/env python3 # SPDX-License-Identifier: GPL-2.0-only # # Copyright (C) 2025 ImmortalWrt.org # # Report how much of the gettext template (po/templates/homeproxy-pro.pot) is # translated in po/zh_Hans/homeproxy-pro.po. Missing, empty and fuzzy entries all # count as untranslated. The check only warns by default so it does not block # a build; pass --fail-below to turn the warning into a failure. # # A template that parses to fewer than MIN_TOTAL non-ignored entries is a hard # failure regardless of flags: an empty or truncated .pot makes every # percentage meaningless, and a coverage number must not be allowed to report # 100% for a translation set that covers nothing. # # Two defects are hard failures whatever the flags say, because neither shows # up in the percentage: a msgstr whose placeholders do not match its msgid, and # a msgstr that repeats a 20+ character run of itself. # # Usage: # tests/i18n-coverage.py # warn below 95% # tests/i18n-coverage.py --warn-below 90 # tests/i18n-coverage.py --fail-below 95 # tests/i18n-coverage.py --list 20 import argparse import os import re import sys ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) TEMPLATE = os.path.join(ROOT, "po/templates/homeproxy-pro.pot") TRANSLATION = os.path.join(ROOT, "po/zh_Hans/homeproxy-pro.po") IGNORE = os.path.join(ROOT, "tests/i18n-ignore.txt") # The .pot is generated from the shipped sources and currently parses to 737 # non-empty msgid entries, of which tests/i18n-ignore.txt exempts enough to # leave 729 checked. 400 is a deliberately low floor - a bit over half the # current corpus - chosen to catch an empty or truncated template without # failing on a legitimate future removal of some strings. 0 must never be # accepted: `translated / total` used to fall back to 100% when total was 0, # so a wiped .pot passed both --warn-below 100 and --fail-below 100. MIN_TOTAL = 400 # A translation that says the same thing twice (the TUIC entry shipped with the # whole sentence in two half-translations) is invisible to the coverage # percentage - the msgstr is neither empty nor fuzzy - and to the placeholder # check - the conversions still match. Any run of DUPLICATE_MIN characters # that occurs twice inside one msgstr fails the check, whatever --fail-below # says. # # Exclusion rule (why the markup-heavy entries in this catalogue do not trip # it): LuCI strings mix prose with HTML, so a *correct* translation can repeat # a structural run many times, e.g. the default-rule entry carries # " &&
(port || port_range)" three times over. Those tags are # template scaffolding, not translated wording, so every <...> tag is stripped # before the scan. A duplicated clause keeps far more than 20 characters of # real text after stripping, while the longest structural run above collapses # to ") &&(" - below the threshold. Without the strip that entry would be a # false positive. DUPLICATE_MIN = 20 TAG_RE = re.compile(r"<[^>]*>") def load_ignore(path): """msgids that are deliberately not translated (technical tokens).""" if not os.path.exists(path): return set() ignored = set() with open(path, encoding="utf-8") as handle: for raw in handle: line = raw.strip() if line and not line.startswith("#"): ignored.add(line) return ignored def unquote(value): value = value.strip() if len(value) >= 2 and value[0] == '"' and value[-1] == '"': return value[1:-1] return value def parse_po(path): """Return {msgid: {'msgstr': str, 'fuzzy': bool}} for non-empty msgids.""" entries = {} msgid = None msgstr = "" state = None pending_fuzzy = False entry_fuzzy = False def flush(): if msgid: entries[msgid] = {"msgstr": msgstr, "fuzzy": entry_fuzzy} with open(path, encoding="utf-8") as handle: for raw in handle: line = raw.rstrip("\n") if line.startswith("#,"): if "fuzzy" in line: pending_fuzzy = True continue if line.startswith("#"): continue if line.startswith("msgid "): flush() msgid = unquote(line[len("msgid "):]) msgstr = "" entry_fuzzy = pending_fuzzy pending_fuzzy = False state = "msgid" elif line.startswith("msgstr "): msgstr = unquote(line[len("msgstr "):]) state = "msgstr" elif line.startswith('"'): if state == "msgid": msgid += unquote(line) elif state == "msgstr": msgstr += unquote(line) flush() return entries def placeholder_counts(text): """How many %s/%d/%j conversions a string carries. LuCI's String.format() silently substitutes an empty string for a missing argument, so a translation with extra placeholders renders an empty and one with too few drops whatever the source interpolated. Neither shows up in the coverage percentage - only the presence and the completeness of the msgstr are checked - which is how two zh_Hans entries shipped with four placeholders against a two-placeholder msgid.""" return len(re.findall(r"%(?:[sdj])", text)) def find_repeats(text, minimum=DUPLICATE_MIN): """Repeated runs of >= minimum characters inside one translated string. Returns a list of (start, length, fragment) triplets, longest run first, with overlapping reports of the same run collapsed into one. HTML tags are stripped first (see DUPLICATE_MIN above for why).""" text = TAG_RE.sub("", text) length = len(text) if length < minimum * 2: return [] hits = [] seen = {} for index in range(length - minimum + 1): gram = text[index:index + minimum] first = seen.setdefault(gram, index) if first == index: continue # Grow the match in both directions so the reported fragment is the # maximal repeated run rather than the first 20-character window. end = minimum while (index + end < length and first + end < length and text[first + end] == text[index + end]): end += 1 back = 0 while (index - back > 0 and first - back > 0 and text[first - back - 1] == text[index - back - 1]): back += 1 hits.append((first - back, index - back, end + back, text[first - back:first - back + end + back])) hits.sort(key=lambda hit: (-hit[2], hit[0], hit[1])) kept = [] for hit in hits: if any(hit[0] >= other[0] and hit[0] < other[0] + other[2] for other in kept): continue kept.append(hit) return kept def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--warn-below", type=float, default=100.0, help="warn when coverage is below this percentage (default: 100)") parser.add_argument("--fail-below", type=float, default=None, help="exit non-zero when coverage is below this percentage") parser.add_argument("--list", type=int, default=10, help="list at most this many untranslated entries (default: 10)") args = parser.parse_args() template = parse_po(TEMPLATE) translation = parse_po(TRANSLATION) ignored = load_ignore(IGNORE) total = 0 untranslated = [] for msgid in template: if msgid in ignored: continue total += 1 entry = translation.get(msgid) if not entry or entry["fuzzy"] or not entry["msgstr"].strip(): untranslated.append(msgid) # A translated string has to keep the msgid's placeholders: the count, not # the letters around them. Checked on its own so a bad translation cannot # hide behind a 100% coverage number. mismatched = [] for msgid, entry in translation.items(): if msgid not in template or entry["fuzzy"] or not entry["msgstr"].strip(): continue want, got = placeholder_counts(msgid), placeholder_counts(entry["msgstr"]) if want != got: mismatched.append((msgid, want, got)) translated = total - len(untranslated) coverage = (translated / total * 100) if total else 100.0 print(f"zh_Hans translation coverage: {translated}/{total} ({coverage:.1f}%)" f", {len(ignored)} msgid(s) ignored by tests/i18n-ignore.txt") if mismatched: print(f"::error title=Translation placeholders::{len(mismatched)} translated" f" string(s) do not carry the same number of %s/%d/%j conversions as" f" their msgid - LuCI's format() renders an empty fragment for a" f" missing argument and drops the extra ones") for msgid, want, got in mismatched[:args.list]: print(f" - msgid has {want}, msgstr has {got}: {msgid}") return 1 # Same idea as the placeholder check: a msgstr that repeats itself counts # as translated, so only this scan can catch it. Hard failure, not tied to # --fail-below. duplicated = [] for msgid, entry in translation.items(): if entry["fuzzy"] or not entry["msgstr"].strip(): continue for _start, length, _second, fragment in find_repeats(entry["msgstr"]): duplicated.append((msgid, length, fragment)) if duplicated: print(f"::error title=Duplicate translation::{len(duplicated)} msgstr(s)" f" repeat a run of {DUPLICATE_MIN}+ characters inside themselves -" f" the entry looks translated but ships the same wording twice") for msgid, length, fragment in duplicated[:args.list]: print(f" - {length} chars repeated: {fragment!r} in: {msgid}") return 1 # A denominator below the floor means the template is empty or truncated, # not that the translation is perfect. Fail hard whatever the flags say; # this is a template-integrity check, not a coverage threshold. if total < MIN_TOTAL: print(f"::error title=Translation coverage::the gettext template parsed" f" to only {total} non-ignored msgid(s), below the {MIN_TOTAL}" f" floor - the .pot is empty or truncated, so {coverage:.1f}%" f" coverage is meaningless") return 1 if untranslated and args.list: print(f"{len(untranslated)} untranslated entries, first {min(args.list, len(untranslated))}:") for msgid in untranslated[:args.list]: print(f" - {msgid}") summary = os.environ.get("GITHUB_STEP_SUMMARY") if summary: with open(summary, "a", encoding="utf-8") as handle: handle.write("## zh_Hans translation coverage\n\n") handle.write(f"**{translated}/{total} ({coverage:.1f}%)** — " f"{len(untranslated)} untranslated entry/entries.\n\n") if untranslated: handle.write("
Untranslated\n\n") for msgid in untranslated: handle.write(f"- `{msgid}`\n") handle.write("\n
\n") if args.fail_below is not None and coverage < args.fail_below: print(f"::error title=Translation coverage::zh_Hans coverage {coverage:.1f}% " f"is below {args.fail_below}%") return 1 if coverage < args.warn_below: # --warn-below is advisory on purpose (build.yml records the number # instead of gating a release on it), so the message must say out loud # that this did not fail anything; otherwise a red annotation reads # like a gate that the exit status contradicts. print(f"::warning title=Translation coverage::zh_Hans coverage {coverage:.1f}% " f"is below {args.warn_below}% ({len(untranslated)} untranslated entry/entries)" f" - advisory only, this did NOT fail the build; use --fail-below" f" {args.warn_below:g} to make it a gate") return 0 if __name__ == "__main__": sys.exit(main())