Files
cacti/scripts/validate_cacti_po.py
Thomas Vincent 77301a1683 fix(i18n): add PO format-string validator (#6839)
Add script to validate PO files for format-string mismatches.
Runs msgfmt --check-format on all locale files.

- scripts/validate_cacti_po.py
- Can be run locally or in CI

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
2026-03-18 21:11:41 -04:00

86 lines
4.0 KiB
Python
Executable File

#!/usr/bin/env python3
"""Cacti PO file validator -- catches format string errors before runtime."""
from __future__ import annotations
import re, shutil, subprocess, sys, ast
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Set
PERCENT_RE = re.compile(r'%(?:\((?P<mapping>[^\)]+)\))?[#0\- +\']*(?:\d+|\*)?(?:\.(?:\d+|\*))?[hlL]?(?P<type>[diouxXeEfFgGcrs%])', re.VERBOSE)
@dataclass
class Entry:
comments: List[str] = field(default_factory=list)
flags: Set[str] = field(default_factory=set)
msgid: Optional[str] = None
msgid_plural: Optional[str] = None
msgstr: Dict[int, str] = field(default_factory=dict)
lineno: int = 0
def po_unquote(s: str) -> str:
return ast.literal_eval(s.strip())
def parse_po(path: Path) -> List[Entry]:
lines = path.read_text(encoding="utf-8").splitlines()
entries, cur = [], Entry()
active_field, active_index = None, None
def flush():
nonlocal cur, active_field, active_index
if cur.msgid is not None or cur.msgstr or cur.comments:
entries.append(cur)
cur.__init__(); active_field = active_index = None
for lineno, line in enumerate(lines, 1):
line = line.rstrip()
if not line.strip(): flush(); continue
if line.startswith("#,"): cur.comments.append(line); cur.flags.update(x.strip() for x in line[2:].split(",") if x.strip()); continue
if line.startswith("#"): cur.comments.append(line); continue
if line.startswith("msgid_plural "): cur.lineno = cur.lineno or lineno; cur.msgid_plural = po_unquote(line[len("msgid_plural "):]); active_field = "msgid_plural"; continue
if line.startswith("msgid "): cur.lineno = cur.lineno or lineno; cur.msgid = po_unquote(line[len("msgid "):]); active_field = "msgid"; continue
if line.startswith("msgstr["): close = line.index("]"); idx = int(line[len("msgstr["):close]); cur.msgstr[idx] = po_unquote(line[close+1:].strip()); active_field = "msgstr"; active_index = idx; continue
if line.startswith("msgstr "): cur.lineno = cur.lineno or lineno; cur.msgstr[0] = po_unquote(line[len("msgstr "):]); active_field = "msgstr"; active_index = 0; continue
if line.startswith('"'):
piece = po_unquote(line)
if active_field == "msgid": cur.msgid = (cur.msgid or "") + piece
elif active_field == "msgid_plural": cur.msgid_plural = (cur.msgid_plural or "") + piece
elif active_field == "msgstr" and active_index is not None: cur.msgstr[active_index] = cur.msgstr.get(active_index, "") + piece
continue
flush()
return entries
def percent_tokens(text: str) -> List[str]:
return sorted(m.group(0) for m in PERCENT_RE.finditer(text) if m.group(0) != "%%")
def validate_file(path: Path) -> List[str]:
errors = []
msgfmt = shutil.which("msgfmt")
if msgfmt:
proc = subprocess.run([msgfmt, "-c", "--check-format", "-o", "/dev/null", str(path)], capture_output=True, text=True)
if proc.returncode != 0: errors.append(f"{path}: msgfmt failed:\n{proc.stderr.strip()}")
try:
entries = parse_po(path)
except Exception as exc:
errors.append(f"{path}: parse failure: {exc}"); return errors
for entry in entries:
if not entry.msgid: continue
src = percent_tokens(entry.msgid)
if not src: continue
for idx, tr in sorted(entry.msgstr.items()):
if not tr: continue
tr_tokens = percent_tokens(tr)
if src != tr_tokens:
errors.append(f"{path}:{entry.lineno}: msgstr[{idx}] format mismatch src={src} tr={tr_tokens}")
return errors
def main(argv: List[str]) -> int:
files = [Path(a) for a in argv[1:] if a.endswith(".po")]
all_errors = []
for path in files:
if path.exists(): all_errors.extend(validate_file(path))
if all_errors:
for err in all_errors: print(f"- {err}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))