"""Pure Python tools for managing .po translation files. Replaces GNU gettext CLI tools (xgettext, msginit, msgmerge) with Python implementations for extracting, creating, and updating .po files. """ import ast import os import re from dataclasses import dataclass, field from pathlib import Path @dataclass class POEntry: msgid: str = "" msgstr: str = "" msgid_plural: str = "" msgstr_plural: dict[int, str] = field(default_factory=dict) comments: list[str] = field(default_factory=list) source_refs: list[str] = field(default_factory=list) flags: list[str] = field(default_factory=list) @property def is_plural(self) -> bool: return bool(self.msgid_plural) # ──── PO file parsing ──── def _parse_quoted(s: str) -> str: """Parse a PO-format quoted string, handling escape sequences.""" s = s.strip() if not (s.startswith('"') and s.endswith('"')): raise ValueError(f"Expected quoted string: {s!r}") s = s[1:-1] result = [] i = 0 while i < len(s): if s[i] == '\\' and i + 1 < len(s): c = s[i + 1] if c == 'n': result.append('\n') elif c == 't': result.append('\t') elif c == '"': result.append('"') elif c == '\\': result.append('\\') else: result.append(s[i:i + 2]) i += 2 else: result.append(s[i]) i += 1 return ''.join(result) def parse_po(path: str | Path) -> tuple[POEntry | None, list[POEntry]]: """Parse a .po/.pot file. Returns (header_entry, entries).""" with open(path, encoding='utf-8') as f: lines = f.readlines() entries: list[POEntry] = [] header: POEntry | None = None cur: POEntry | None = None cur_field: str | None = None plural_idx = 0 def finish(): nonlocal cur, header if cur is None: return if cur.msgid == "" and cur.msgstr: header = cur elif cur.msgid != "" or cur.is_plural: entries.append(cur) cur = None for raw in lines: line = raw.rstrip('\n') stripped = line.strip() if not stripped: finish() cur_field = None continue # Skip obsolete entries if stripped.startswith('#~'): continue if stripped.startswith('#'): if cur is None: cur = POEntry() if stripped.startswith('#:'): cur.source_refs.append(stripped[2:].strip()) elif stripped.startswith('#,'): cur.flags.extend(f.strip() for f in stripped[2:].split(',') if f.strip()) else: cur.comments.append(line) continue if stripped.startswith('msgid_plural '): if cur is None: cur = POEntry() cur.msgid_plural = _parse_quoted(stripped[len('msgid_plural '):]) cur_field = 'msgid_plural' continue if stripped.startswith('msgid '): if cur is None: cur = POEntry() cur.msgid = _parse_quoted(stripped[len('msgid '):]) cur_field = 'msgid' continue m = re.match(r'msgstr\[(\d+)]\s+(.*)', stripped) if m: plural_idx = int(m.group(1)) cur.msgstr_plural[plural_idx] = _parse_quoted(m.group(2)) cur_field = 'msgstr_plural' continue if stripped.startswith('msgstr '): cur.msgstr = _parse_quoted(stripped[len('msgstr '):]) cur_field = 'msgstr' continue if stripped.startswith('"'): val = _parse_quoted(stripped) if cur_field == 'msgid': cur.msgid += val elif cur_field == 'msgid_plural': cur.msgid_plural += val elif cur_field == 'msgstr': cur.msgstr += val elif cur_field == 'msgstr_plural': cur.msgstr_plural[plural_idx] += val finish() return header, entries # ──── PO file writing ──── def _quote(s: str) -> str: """Quote a string for .po file output.""" s = s.replace('\\', '\\\\').replace('"', '\\"').replace('\t', '\\t') if '\n' in s and s != '\n': parts = s.split('\n') lines = ['""'] for i, part in enumerate(parts): text = part + ('\\n' if i < len(parts) - 1 else '') if text: lines.append(f'"{text}"') return '\n'.join(lines) return f'"{s}"'.replace('\n', '\\n') def write_po(path: str | Path, header: POEntry | None, entries: list[POEntry]) -> None: """Write a .po/.pot file.""" with open(path, 'w', encoding='utf-8') as f: if header: for c in header.comments: f.write(c + '\n') f.write(f'msgid {_quote("")}\n') f.write(f'msgstr {_quote(header.msgstr)}\n\n') for entry in entries: for c in entry.comments: f.write(c + '\n') # Keep file-level context for translators, but drop line numbers to # avoid churning PO diffs on unrelated code edits. source_files = sorted({ref.rsplit(':', 1)[0] for ref in entry.source_refs}) for ref in source_files: f.write(f'#: {ref}\n') # Runtime loading ignores gettext flags; omit them to reduce noise. f.write(f'msgid {_quote(entry.msgid)}\n') if entry.is_plural: f.write(f'msgid_plural {_quote(entry.msgid_plural)}\n') for idx in sorted(entry.msgstr_plural): f.write(f'msgstr[{idx}] {_quote(entry.msgstr_plural[idx])}\n') else: f.write(f'msgstr {_quote(entry.msgstr)}\n') f.write('\n') # ──── String extraction (replaces xgettext) ──── def extract_strings(files: list[str], basedir: str) -> list[POEntry]: """Extract tr/trn/tr_noop calls from Python source files.""" seen: dict[str, POEntry] = {} for filepath in files: full = os.path.join(basedir, filepath) with open(full, encoding='utf-8') as f: source = f.read() try: tree = ast.parse(source, filename=filepath) except SyntaxError: continue for node in ast.walk(tree): if not isinstance(node, ast.Call): continue func = node.func if isinstance(func, ast.Name): name = func.id elif isinstance(func, ast.Attribute): name = func.attr else: continue if name not in ('tr', 'trn', 'tr_noop'): continue ref = f'{filepath}:{node.lineno}' is_flagged = name in ('tr', 'trn') if name in ('tr', 'tr_noop'): if not node.args or not isinstance(node.args[0], ast.Constant) or not isinstance(node.args[0].value, str): continue msgid = node.args[0].value if msgid in seen: if ref not in seen[msgid].source_refs: seen[msgid].source_refs.append(ref) else: flags = ['python-format'] if is_flagged else [] seen[msgid] = POEntry(msgid=msgid, source_refs=[ref], flags=flags) elif name == 'trn': if len(node.args) < 2: continue a1, a2 = node.args[0], node.args[1] if not (isinstance(a1, ast.Constant) and isinstance(a1.value, str)): continue if not (isinstance(a2, ast.Constant) and isinstance(a2.value, str)): continue msgid, msgid_plural = a1.value, a2.value if msgid in seen: if ref not in seen[msgid].source_refs: seen[msgid].source_refs.append(ref) else: flags = ['python-format'] if is_flagged else [] seen[msgid] = POEntry( msgid=msgid, msgid_plural=msgid_plural, source_refs=[ref], flags=flags, msgstr_plural={0: '', 1: ''}, ) return list(seen.values()) # ──── POT generation ──── def _build_pot_header() -> POEntry: return POEntry( msgstr='Content-Type: text/plain; charset=UTF-8\n', ) def _build_po_header(language: str) -> POEntry: plural_forms = PLURAL_FORMS.get(language, 'nplurals=2; plural=(n != 1);') return POEntry( msgstr='Content-Type: text/plain; charset=UTF-8\n' + f'Language: {language}\n' + f'Plural-Forms: {plural_forms}\n', ) def generate_pot(entries: list[POEntry], pot_path: str | Path) -> None: """Generate a .pot template file from extracted entries.""" write_po(pot_path, _build_pot_header(), entries) # ──── PO init (replaces msginit) ──── PLURAL_FORMS: dict[str, str] = { 'en': 'nplurals=2; plural=(n != 1);', 'de': 'nplurals=2; plural=(n != 1);', 'fr': 'nplurals=2; plural=(n > 1);', 'es': 'nplurals=2; plural=(n != 1);', 'pt-BR': 'nplurals=2; plural=(n > 1);', 'tr': 'nplurals=2; plural=(n != 1);', 'uk': 'nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);', 'th': 'nplurals=1; plural=0;', 'zh-CHT': 'nplurals=1; plural=0;', 'zh-CHS': 'nplurals=1; plural=0;', 'ko': 'nplurals=1; plural=0;', 'ja': 'nplurals=1; plural=0;', } def init_po(pot_path: str | Path, po_path: str | Path, language: str) -> None: """Create a new .po file from a .pot template (replaces msginit).""" _, entries = parse_po(pot_path) plural_forms = PLURAL_FORMS.get(language, 'nplurals=2; plural=(n != 1);') nplurals = int(re.search(r'nplurals=(\d+)', plural_forms).group(1)) for e in entries: if e.is_plural: e.msgstr_plural = dict.fromkeys(range(nplurals), '') write_po(po_path, _build_po_header(language), entries) # ──── PO merge (replaces msgmerge) ──── def merge_po(po_path: str | Path, pot_path: str | Path) -> None: """Update a .po file with entries from a .pot template (replaces msgmerge --update).""" _, po_entries = parse_po(po_path) _, pot_entries = parse_po(pot_path) language = Path(po_path).stem.removeprefix("app_") existing = {e.msgid: e for e in po_entries} merged = [] for pot_e in pot_entries: if pot_e.msgid in existing: old = existing[pot_e.msgid] old.source_refs = pot_e.source_refs old.flags = pot_e.flags old.comments = pot_e.comments if pot_e.is_plural: old.msgid_plural = pot_e.msgid_plural merged.append(old) else: merged.append(pot_e) merged.sort(key=lambda e: e.msgid) write_po(po_path, _build_po_header(language), merged)