mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-07-13 11:22:07 +08:00
334 lines
9.7 KiB
Python
334 lines
9.7 KiB
Python
"""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)
|