Path: blob/master/scripts/verify_translation_placeholders.py
4201 views
1#!/usr/bin/env python32"""3Qt Translation Placeholder Verifier45This script verifies that placeholders in Qt translation files are consistent6between source and translation strings.78Placeholder rules:9- {} placeholders: translation must have same total count as source10- {n} placeholders: translation must use same numbers as source (can repeat)11"""1213import xml.etree.ElementTree as ET14import re15import sys16from typing import List, Set, Tuple, Dict17from pathlib import Path181920def extract_placeholders(text: str) -> Tuple[int, Set[int]]:21"""22Extract placeholder information from a string.2324Returns:25Tuple of (unnamed_count, set_of_numbered_placeholders)26"""27# Find all placeholders28placeholders = re.findall(r'\{(\d*)\}', text)2930unnamed_count = 031numbered_set = set()3233for placeholder in placeholders:34if placeholder == '':35unnamed_count += 136else:37numbered_set.add(int(placeholder))3839return unnamed_count, numbered_set404142def verify_placeholders(source: str, translation: str) -> Tuple[bool, str]:43"""44Verify that placeholders in source and translation are consistent.4546Returns:47Tuple of (is_valid, error_message)48"""49if not translation.strip():50return True, "" # Empty translations are valid5152source_unnamed, source_numbered = extract_placeholders(source)53trans_unnamed, trans_numbered = extract_placeholders(translation)5455# Check if mixing numbered and unnumbered placeholders56if source_unnamed > 0 and len(source_numbered) > 0:57return False, "Source mixes numbered and unnumbered placeholders"5859if trans_unnamed > 0 and len(trans_numbered) > 0:60return False, "Translation mixes numbered and unnumbered placeholders"6162# If source uses unnumbered placeholders63if source_unnamed > 0:64# Translation can use either unnumbered or numbered placeholders65if trans_unnamed > 0:66# Both use unnumbered - simple count match67if source_unnamed != trans_unnamed:68return False, f"Placeholder count mismatch: source has {source_unnamed}, translation has {trans_unnamed}"69elif len(trans_numbered) > 0:70# Source uses unnumbered, translation uses numbered71# Check that numbered placeholders are 0-based and consecutive up to source count72expected_numbers = set(range(source_unnamed))73if trans_numbered != expected_numbers:74if max(trans_numbered) >= source_unnamed:75return False, f"Numbered placeholders exceed source count: translation uses {{{max(trans_numbered)}}}, but source only has {source_unnamed} placeholders"76elif min(trans_numbered) != 0:77return False, f"Numbered placeholders must start from 0: found {{{min(trans_numbered)}}}"78else:79missing = expected_numbers - trans_numbered80return False, f"Missing numbered placeholders: {{{','.join(map(str, sorted(missing)))}}}"8182# If source uses numbered placeholders83elif len(source_numbered) > 0:84if trans_unnamed > 0:85return False, "Source uses numbered {n} but translation uses unnumbered {}"86if source_numbered != trans_numbered:87missing = source_numbered - trans_numbered88extra = trans_numbered - source_numbered89error_parts = []90if missing:91error_parts.append(f"missing {{{','.join(map(str, sorted(missing)))}}}")92if extra:93error_parts.append(f"extra {{{','.join(map(str, sorted(extra)))}}}")94return False, f"Numbered placeholder mismatch: {', '.join(error_parts)}"9596# If translation has placeholders but source doesn't97elif trans_unnamed > 0 or len(trans_numbered) > 0:98return False, "Translation has placeholders but source doesn't"99100return True, ""101102103def verify_translation_file(file_path: str) -> List[Dict]:104"""105Verify all translations in a Qt translation file.106107Returns:108List of error dictionaries with details about invalid translations109"""110try:111tree = ET.parse(file_path)112root = tree.getroot()113except ET.ParseError as e:114return [{"error": f"XML parsing error: {e}", "line": None}]115except FileNotFoundError:116return [{"error": f"File not found: {file_path}", "line": None}]117118errors = []119120# Find all message elements121for message in root.findall('.//message'):122source_elem = message.find('source')123translation_elem = message.find('translation')124location_elem = message.find('location')125126if source_elem is None or translation_elem is None:127continue128129source_text = source_elem.text or ""130translation_text = translation_elem.text or ""131132is_valid, error_msg = verify_placeholders(source_text, translation_text)133134if not is_valid:135error_info = {136"source": source_text,137"translation": translation_text,138"error": error_msg,139"line": location_elem.get('line') if location_elem is not None else None,140"filename": location_elem.get('filename') if location_elem is not None else None141}142errors.append(error_info)143144return errors145146147def main():148if len(sys.argv) != 2:149print(f"Usage: {sys.argv[0]} <translation_file.ts>")150sys.exit(1)151152file_path = sys.argv[1]153154if not Path(file_path).exists():155print(f"Error: File '{file_path}' not found")156sys.exit(1)157158print(f"Verifying placeholders in: {file_path}")159160errors = verify_translation_file(file_path)161162if not errors:163print("All placeholders are valid.")164sys.exit(0)165166print(f"Found {len(errors)} placeholder errors:")167168for i, error in enumerate(errors, 1):169print(f"\nError {i}:")170if error.get('filename') and error.get('line'):171print(f" Location: {error['filename']}:{error['line']}")172print(f" Source: {error['source']}")173print(f" Translation: {error['translation']}")174print(f" Issue: {error['error']}")175176sys.exit(1)177178179if __name__ == "__main__":180main()181182