check-translations.py (9069B)
1 #!/usr/bin/env python3 2 """ 3 check-translations.py 4 5 Finds all Android string resources by first locating the git root (.git folder), 6 allowing to run it reliably from sub folders. 7 8 Usage: 9 python3 check-translations.py # Overview of all languages 10 python3 check-translations.py de # Missing DE strings per module 11 python3 check-translations.py de --lint # + format-arg / array lint checks 12 """ 13 14 from __future__ import annotations 15 16 import argparse 17 import re 18 import sys 19 import xml.etree.ElementTree as ET 20 from collections import defaultdict 21 from pathlib import Path 22 23 24 PLACEHOLDER_RE = re.compile(r"%(?:(\d+)\$)?([%sdf])") 25 26 27 def find_git_root(start: Path) -> Path | None: 28 """Walk upwards until we find a .git directory.""" 29 current = start.resolve() 30 while current != current.parent: 31 if (current / ".git").exists(): 32 return current 33 current = current.parent 34 return None 35 36 37 def get_string_names(xml_path: Path) -> set[str]: 38 if not xml_path.exists(): 39 return set() 40 try: 41 tree = ET.parse(xml_path) 42 return { 43 elem.get("name") 44 for elem in tree.findall(".//string") 45 if elem.get("name") and elem.get("translatable") != "false" 46 } 47 except ET.ParseError: 48 return set() 49 50 51 def get_string_map(xml_path: Path) -> dict[str, str]: 52 """name -> text content (translatable strings only).""" 53 if not xml_path.exists(): 54 return {} 55 try: 56 root = ET.parse(xml_path).getroot() 57 except ET.ParseError: 58 return {} 59 out: dict[str, str] = {} 60 for elem in root.findall(".//string"): 61 name = elem.get("name") 62 if not name or elem.get("translatable") == "false": 63 continue 64 out[name] = "".join(elem.itertext()) 65 return out 66 67 68 def get_string_array_items(xml_path: Path) -> dict[str, list[str]]: 69 if not xml_path.exists(): 70 return {} 71 try: 72 root = ET.parse(xml_path).getroot() 73 except ET.ParseError: 74 return {} 75 out: dict[str, list[str]] = {} 76 for arr in root.findall(".//string-array"): 77 name = arr.get("name") 78 if not name: 79 continue 80 out[name] = ["".join(item.itertext()) for item in arr.findall("item")] 81 return out 82 83 84 def normalize_placeholders(text: str) -> list[tuple[int, str]]: 85 """Return sorted (index, type) list for Android format placeholders.""" 86 result: list[tuple[int, str]] = [] 87 auto = 1 88 for idx, typ in PLACEHOLDER_RE.findall(text): 89 if typ == "%": 90 continue 91 if idx: 92 result.append((int(idx), typ)) 93 else: 94 result.append((auto, typ)) 95 auto += 1 96 return sorted(result) 97 98 99 def find_all_res_directories(git_root: Path) -> list[Path]: 100 """Find all res/ folders inside the git repository.""" 101 res_dirs = [] 102 for values_dir in git_root.rglob("values"): 103 if (values_dir / "strings.xml").exists(): 104 # skip build outputs 105 if "build" in values_dir.parts: 106 continue 107 res_dirs.append(values_dir.parent) 108 return sorted(set(res_dirs)) 109 110 111 def module_name(git_root: Path, res_dir: Path) -> str: 112 try: 113 return res_dir.relative_to(git_root).parts[0] 114 except Exception: 115 return str(res_dir) 116 117 118 def lint_module( 119 git_root: Path, 120 res_dir: Path, 121 lang: str, 122 ) -> list[str]: 123 """Lint-like checks for one language vs English reference.""" 124 ref_file = res_dir / "values" / "strings.xml" 125 loc_file = res_dir / f"values-{lang}" / "strings.xml" 126 if not ref_file.exists() or not loc_file.exists(): 127 return [] 128 129 issues: list[str] = [] 130 mod = module_name(git_root, res_dir) 131 ref_map = get_string_map(ref_file) 132 loc_map = get_string_map(loc_file) 133 ref_names = set(ref_map) 134 loc_names = set(loc_map) 135 136 for name in sorted(ref_names - loc_names): 137 issues.append(f"{mod}: MissingTranslation: {name}") 138 139 for name in sorted(loc_names - ref_names): 140 issues.append(f"{mod}: ExtraTranslation: {name}") 141 142 for name in sorted(ref_names & loc_names): 143 en_ph = normalize_placeholders(ref_map[name]) 144 tr_ph = normalize_placeholders(loc_map[name]) 145 if en_ph != tr_ph: 146 issues.append( 147 f"{mod}: StringFormatCount: {name} " 148 f"(en={en_ph} {lang}={tr_ph})" 149 ) 150 151 ref_arrays = get_string_array_items(ref_file) 152 loc_arrays = get_string_array_items(loc_file) 153 for aname, ref_items in sorted(ref_arrays.items()): 154 if aname not in loc_arrays: 155 # not all locales redefine arrays; only check when present 156 continue 157 loc_items = loc_arrays[aname] 158 if len(ref_items) != len(loc_items): 159 issues.append( 160 f"{mod}: StringArrayLength: {aname} " 161 f"(en={len(ref_items)} {lang}={len(loc_items)})" 162 ) 163 # values/labels pairing when both exist in locale 164 if ( 165 "settings_language_values" in loc_arrays 166 and "settings_language_labels" in loc_arrays 167 ): 168 nv = len(loc_arrays["settings_language_values"]) 169 nl = len(loc_arrays["settings_language_labels"]) 170 if nv != nl: 171 issues.append( 172 f"{mod}: StringArrayPair: settings_language_values/" 173 f"labels length {nv}!={nl}" 174 ) 175 176 return issues 177 178 179 def main() -> int: 180 parser = argparse.ArgumentParser( 181 description="Check Android translation coverage and optional lint." 182 ) 183 parser.add_argument( 184 "lang", 185 nargs="?", 186 help="Language code (e.g. de or fr)", 187 ) 188 parser.add_argument( 189 "--lint", 190 action="store_true", 191 help=( 192 "Extra checks: format placeholders, extra keys, " 193 "string-array lengths (for LANG, or all langs if LANG omitted)" 194 ), 195 ) 196 args = parser.parse_args() 197 198 git_root = find_git_root(Path.cwd()) 199 if not git_root: 200 print( 201 "Could not find a .git directory. " 202 "Please run from inside a git repository." 203 ) 204 return 1 205 206 print(f"Git root: {git_root}\n") 207 208 res_dirs = find_all_res_directories(git_root) 209 if not res_dirs: 210 print("No Android string resources found in this repository.") 211 return 1 212 213 print(f"Found {len(res_dirs)} resource folder(s)\n") 214 215 lang_data: dict[str, list[dict]] = defaultdict(list) 216 217 for res_dir in res_dirs: 218 ref_file = res_dir / "values" / "strings.xml" 219 if not ref_file.exists(): 220 continue 221 222 ref_names = get_string_names(ref_file) 223 if not ref_names: 224 continue 225 226 for values_dir in res_dir.glob("values-*"): 227 lang_code = values_dir.name.removeprefix("values-") 228 strings_file = values_dir / "strings.xml" 229 if not strings_file.exists(): 230 continue 231 232 present = get_string_names(strings_file) 233 missing = ref_names - present 234 235 lang_data[lang_code].append( 236 { 237 "res_dir": res_dir, 238 "missing_count": len(missing), 239 "missing_names": sorted(missing), 240 } 241 ) 242 243 print("Language Folders Strings Missing") 244 print("-" * 35) 245 for lang in sorted(lang_data.keys()): 246 entries = lang_data[lang] 247 total_missing = sum(e["missing_count"] for e in entries) 248 print(f"{lang:<10} {len(entries):>7} {total_missing:>6}") 249 250 print() 251 252 exit_code = 0 253 254 if args.lang: 255 lang = args.lang 256 if lang not in lang_data: 257 print(f"No '{lang}' translations found.") 258 return 1 259 260 print(f"=== Missing strings in '{lang}' ===\n") 261 262 for entry in lang_data[lang]: 263 res_dir = entry["res_dir"] 264 missing_names = entry["missing_names"] 265 module = module_name(git_root, res_dir) 266 267 print(f"→ {module} ({entry['missing_count']} missing)") 268 269 if missing_names: 270 exit_code = 1 271 for name in missing_names: 272 print(f" {name}") 273 else: 274 print(" (complete)") 275 print() 276 277 if args.lint: 278 langs = [args.lang] if args.lang else sorted(lang_data.keys()) 279 print(f"=== Lint checks ({', '.join(langs)}) ===\n") 280 any_issue = False 281 for lang in langs: 282 lint_issues: list[str] = [] 283 for res_dir in res_dirs: 284 lint_issues.extend(lint_module(git_root, res_dir, lang)) 285 if not lint_issues: 286 print(f"→ {lang}: clean") 287 else: 288 any_issue = True 289 exit_code = 1 290 print(f"→ {lang}: {len(lint_issues)} issue(s)") 291 for line in lint_issues: 292 print(f" {line}") 293 print() 294 if not any_issue: 295 print("All lint checks passed.") 296 297 return exit_code 298 299 300 if __name__ == "__main__": 301 sys.exit(main())