From f0eaf40d5f5fd4c4f813f482f71505ae216d16f8 Mon Sep 17 00:00:00 2001 From: Softer Date: Mon, 4 May 2026 23:26:10 +0300 Subject: [PATCH 1/3] Fix broken localization: tr(f-string) never matches translation catalog tr(f'Invalid configuration: {error}') evaluates the f-string before tr() runs, so xgettext extracts the literal placeholder as the msgid while runtime passes the formatted string - the two never match. Switch to tr('...{}').format(...) and update msgid in base.pot. --- archinstall/lib/global_menu.py | 2 +- archinstall/locales/base.pot | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/archinstall/lib/global_menu.py b/archinstall/lib/global_menu.py index eac936bdd0..1e18890f77 100644 --- a/archinstall/lib/global_menu.py +++ b/archinstall/lib/global_menu.py @@ -502,7 +502,7 @@ def _prev_install_invalid_config(self, item: MenuItem) -> str | None: return text[:-1] # remove last new line if error := self._validate_bootloader(): - return tr(f'Invalid configuration: {error}') + return tr('Invalid configuration: {}').format(error) self.sync_all_to_config() summary = ConfigurationOutput(self._arch_config).as_summary() diff --git a/archinstall/locales/base.pot b/archinstall/locales/base.pot index 31f5e1960f..1b9ba9d1d7 100644 --- a/archinstall/locales/base.pot +++ b/archinstall/locales/base.pot @@ -1245,7 +1245,7 @@ msgid "Product" msgstr "" #, python-brace-format -msgid "Invalid configuration: {error}" +msgid "Invalid configuration: {}" msgstr "" msgid "Ready to install" From 1f4ebd9414b968f7f3d788d79ff86b5231538fb3 Mon Sep 17 00:00:00 2001 From: Softer Date: Mon, 4 May 2026 23:26:15 +0300 Subject: [PATCH 2/3] Add CI validation for translations and pot_tools dev utility Add translation-check workflow with two jobs: - validate-po: msgfmt --check on changed .po files, .mo sync warning, tr(f-string) anti-pattern grep on changed .py files - validate-pot: verify all tr() strings exist in base.pot when .py files change Workflow only triggers on .py/.po/.pot file changes. Add scripts/pot_tools.py developer utility (stats, list, add_missing) for managing base.pot. --- .github/scripts/check_pot_freshness.py | 56 +++++++++ .github/workflows/translation-check.yaml | 140 ++++++++++++++++++++++ scripts/pot_tools.py | 145 +++++++++++++++++++++++ 3 files changed, 341 insertions(+) create mode 100755 .github/scripts/check_pot_freshness.py create mode 100644 .github/workflows/translation-check.yaml create mode 100755 scripts/pot_tools.py diff --git a/.github/scripts/check_pot_freshness.py b/.github/scripts/check_pot_freshness.py new file mode 100755 index 0000000000..1d2d78e5b3 --- /dev/null +++ b/.github/scripts/check_pot_freshness.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Check that all tr() strings from code exist in base.pot. + +Used by CI. Fails if any translatable string is missing from the committed pot. + +Usage: + xgettext ... -o /tmp/generated.pot + python3 check_pot_freshness.py /tmp/generated.pot locales/base.pot +""" + +import re +import sys +from pathlib import Path + + +def extract_msgids(path: str) -> set[str]: + content = Path(path).read_text() + ids: set[str] = set() + current: str | None = None + + for line in content.splitlines(): + if line.startswith('msgid '): + m = re.search(r'"(.*)"', line) + current = m.group(1) if m else '' + elif current is not None and line.startswith('"'): + m = re.search(r'"(.*)"', line) + if m: + current += m.group(1) + else: + if current is not None and current: + ids.add(current) + current = None + + if current: + ids.add(current) + + return ids + + +def main() -> None: + generated = extract_msgids(sys.argv[1]) + committed = extract_msgids(sys.argv[2]) + + missing = sorted(generated - committed) + + if missing: + print('::error::New tr() strings not in base.pot - run locales_generator.sh:') + for s in missing: + print(f' {s}') + sys.exit(1) + + print('All tr() strings are present in base.pot') + + +if __name__ == '__main__': + main() diff --git a/.github/workflows/translation-check.yaml b/.github/workflows/translation-check.yaml new file mode 100644 index 0000000000..6d99630948 --- /dev/null +++ b/.github/workflows/translation-check.yaml @@ -0,0 +1,140 @@ +name: Translation validation +on: + push: + paths: + - 'archinstall/**/*.py' + - 'archinstall/locales/**' + - '.github/workflows/translation-check.yaml' + - '.github/scripts/check_pot_freshness.py' + pull_request: + paths: + - 'archinstall/**/*.py' + - 'archinstall/locales/**' + - '.github/workflows/translation-check.yaml' + - '.github/scripts/check_pot_freshness.py' +jobs: + validate-po: + name: Validate .po files and translation patterns + runs-on: ubuntu-latest + container: + image: archlinux/archlinux:latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + - name: Find changed files + id: changed + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + base="${{ github.event.pull_request.base.sha }}" + head="${{ github.event.pull_request.head.sha }}" + else + base="${{ github.event.before }}" + head="${{ github.sha }}" + fi + changed_po=$(git diff --name-only "$base" "$head" 2>/dev/null | grep '\.po$' || true) + changed_py=$(git diff --name-only "$base" "$head" 2>/dev/null | grep '\.py$' || true) + if [ -n "$changed_po" ]; then + echo "has_po=true" >> "$GITHUB_OUTPUT" + echo "$changed_po" > /tmp/changed_po.txt + else + echo "has_po=false" >> "$GITHUB_OUTPUT" + fi + if [ -n "$changed_py" ]; then + echo "has_py=true" >> "$GITHUB_OUTPUT" + else + echo "has_py=false" >> "$GITHUB_OUTPUT" + fi + - name: Install gettext + if: steps.changed.outputs.has_po == 'true' + run: | + pacman-key --init + pacman --noconfirm -Sy archlinux-keyring + pacman --noconfirm -Syyu + pacman --noconfirm -S gettext + - name: Check changed .po syntax with msgfmt + if: steps.changed.outputs.has_po == 'true' + run: | + failed=0 + while IFS= read -r po; do + if [ -f "$po" ] && ! msgfmt --check --output-file=/dev/null "$po" 2>&1; then + echo "FAIL: $po" + failed=1 + fi + done < /tmp/changed_po.txt + if [ "$failed" -eq 1 ]; then + echo "::error::Some .po files have syntax errors" + exit 1 + fi + echo "All changed .po files passed syntax check" + - name: Warn if changed .mo files are out of sync + if: steps.changed.outputs.has_po == 'true' + run: | + out_of_sync=0 + while IFS= read -r po; do + if [ ! -f "$po" ]; then continue; fi + mo="${po%.po}.mo" + if [ ! -f "$mo" ]; then + echo "::warning::Missing .mo file for $po" + out_of_sync=1 + continue + fi + tmp_mo=$(mktemp) + msgfmt --output-file="$tmp_mo" "$po" + if ! cmp -s "$mo" "$tmp_mo"; then + echo "::warning::$mo is out of sync with $po - run locales_generator.sh" + out_of_sync=1 + fi + rm -f "$tmp_mo" + done < /tmp/changed_po.txt + if [ "$out_of_sync" -eq 1 ]; then + echo "Some .mo files are out of sync (warning only)" + fi + - name: Check for tr(f-string) anti-pattern + if: steps.changed.outputs.has_py == 'true' + run: | + if grep -rn --include='*.py' "tr(f['\"]" archinstall/; then + echo "::error::Found tr(f'...') pattern. Use tr('...{}').format(...) instead" + exit 1 + fi + echo "No tr(f-string) anti-pattern found" + + validate-pot: + name: Validate base.pot is up to date + runs-on: ubuntu-latest + container: + image: archlinux/archlinux:latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + - name: Check for changed Python files + id: check_py + run: | + if [ "${{ github.event_name }}" = "pull_request" ]; then + base="${{ github.event.pull_request.base.sha }}" + head="${{ github.event.pull_request.head.sha }}" + else + base="${{ github.event.before }}" + head="${{ github.sha }}" + fi + if git diff --name-only "$base" "$head" 2>/dev/null | grep -q '\.py$'; then + echo "has_py_changes=true" >> "$GITHUB_OUTPUT" + else + echo "has_py_changes=false" >> "$GITHUB_OUTPUT" + fi + - name: Install gettext and python + if: steps.check_py.outputs.has_py_changes == 'true' + run: | + pacman-key --init + pacman --noconfirm -Sy archlinux-keyring + pacman --noconfirm -Syyu + pacman --noconfirm -S gettext python + - name: Check for missing translatable strings + if: steps.check_py.outputs.has_py_changes == 'true' + run: | + cd archinstall + find . -type f -name '*.py' | xargs xgettext \ + --no-location --omit-header --keyword='tr' \ + -d base -o /tmp/generated.pot + python3 ../.github/scripts/check_pot_freshness.py /tmp/generated.pot locales/base.pot diff --git a/scripts/pot_tools.py b/scripts/pot_tools.py new file mode 100755 index 0000000000..35bd051beb --- /dev/null +++ b/scripts/pot_tools.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Tools for managing base.pot: find and add missing translatable strings. + +Usage (from repo root): + scripts/pot_tools.py stats + scripts/pot_tools.py list + scripts/pot_tools.py add_missing [--dry-run] + +Requires: gettext (xgettext) installed. +""" + +import os +import re +import subprocess +import sys +import tempfile +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +ARCHINSTALL_DIR = REPO_ROOT / 'archinstall' +BASE_POT = ARCHINSTALL_DIR / 'locales' / 'base.pot' + + +def extract_msgids(path: Path) -> set[str]: + content = path.read_text() + ids: set[str] = set() + current: str | None = None + + for line in content.splitlines(): + if line.startswith('msgid '): + m = re.search(r'"(.*)"', line) + current = m.group(1) if m else '' + elif current is not None and line.startswith('"'): + m = re.search(r'"(.*)"', line) + if m: + current += m.group(1) + else: + if current is not None and current: + ids.add(current) + current = None + + if current: + ids.add(current) + + return ids + + +def generate_fresh_pot() -> Path: + fd, tmp_path = tempfile.mkstemp(suffix='.pot') + os.close(fd) + tmp = Path(tmp_path) + py_files = sorted(str(p) for p in ARCHINSTALL_DIR.rglob('*.py')) + + subprocess.run( + ['xgettext', '--no-location', '--omit-header', + '--keyword=tr', '-d', 'base', '-o', str(tmp)] + py_files, + check=True, + capture_output=True, + ) + return tmp + + +def get_missing(fresh_pot: Path) -> set[str]: + generated = extract_msgids(fresh_pot) + committed = extract_msgids(BASE_POT) + return generated - committed + + +def cmd_stats() -> None: + fresh_pot = generate_fresh_pot() + try: + generated = extract_msgids(fresh_pot) + committed = extract_msgids(BASE_POT) + missing = generated - committed + + print(f'Code: {len(generated)} translatable strings') + print(f'base.pot: {len(committed)} msgids') + print(f' Missing: {len(missing)}') + finally: + fresh_pot.unlink(missing_ok=True) + + +def cmd_list() -> None: + fresh_pot = generate_fresh_pot() + try: + missing = sorted(get_missing(fresh_pot)) + + if missing: + print(f'=== MISSING ({len(missing)}): in code but not in base.pot ===') + for s in missing: + print(f' + {s}') + else: + print('No missing strings') + finally: + fresh_pot.unlink(missing_ok=True) + + +def cmd_add_missing(dry_run: bool = False) -> None: + fresh_pot = generate_fresh_pot() + try: + missing = sorted(get_missing(fresh_pot)) + + if not missing: + print('No missing strings, base.pot is up to date') + return + + print(f'Adding {len(missing)} missing string(s)') + for s in missing: + print(f' + {s}') + + if dry_run: + print('(dry-run, no changes written)') + return + + with open(BASE_POT, 'a') as f: + for s in missing: + if '{' in s: + f.write('\n#, python-brace-format') + f.write(f'\nmsgid "{s}"\nmsgstr ""\n') + + print(f'Done. Added to {BASE_POT}') + finally: + fresh_pot.unlink(missing_ok=True) + + +def main() -> None: + if len(sys.argv) < 2 or sys.argv[1] in ('-h', '--help'): + print('Usage: pot_tools.py {stats|list|add_missing} [--dry-run]') + sys.exit(0) + + cmd = sys.argv[1] + if cmd == 'stats': + cmd_stats() + elif cmd == 'list': + cmd_list() + elif cmd == 'add_missing': + dry_run = '--dry-run' in sys.argv + cmd_add_missing(dry_run) + else: + print(f'Unknown command: {cmd}') + sys.exit(1) + + +if __name__ == '__main__': + main() From 5a95a122b87d32a6efb5aeeadf95a64838eb1e6f Mon Sep 17 00:00:00 2001 From: Softer Date: Mon, 4 May 2026 23:41:33 +0300 Subject: [PATCH 3/3] Fix code style: use tabs and reformat xgettext arguments Align check_pot_freshness.py and pot_tools.py with project indentation (tabs) and ruff format requirements. Sorry :-) --- .github/scripts/check_pot_freshness.py | 60 ++++---- scripts/pot_tools.py | 192 +++++++++++++------------ 2 files changed, 129 insertions(+), 123 deletions(-) diff --git a/.github/scripts/check_pot_freshness.py b/.github/scripts/check_pot_freshness.py index 1d2d78e5b3..8eacaf3553 100755 --- a/.github/scripts/check_pot_freshness.py +++ b/.github/scripts/check_pot_freshness.py @@ -4,8 +4,8 @@ Used by CI. Fails if any translatable string is missing from the committed pot. Usage: - xgettext ... -o /tmp/generated.pot - python3 check_pot_freshness.py /tmp/generated.pot locales/base.pot + xgettext ... -o /tmp/generated.pot + python3 check_pot_freshness.py /tmp/generated.pot locales/base.pot """ import re @@ -14,43 +14,43 @@ def extract_msgids(path: str) -> set[str]: - content = Path(path).read_text() - ids: set[str] = set() - current: str | None = None + content = Path(path).read_text() + ids: set[str] = set() + current: str | None = None - for line in content.splitlines(): - if line.startswith('msgid '): - m = re.search(r'"(.*)"', line) - current = m.group(1) if m else '' - elif current is not None and line.startswith('"'): - m = re.search(r'"(.*)"', line) - if m: - current += m.group(1) - else: - if current is not None and current: - ids.add(current) - current = None + for line in content.splitlines(): + if line.startswith('msgid '): + m = re.search(r'"(.*)"', line) + current = m.group(1) if m else '' + elif current is not None and line.startswith('"'): + m = re.search(r'"(.*)"', line) + if m: + current += m.group(1) + else: + if current is not None and current: + ids.add(current) + current = None - if current: - ids.add(current) + if current: + ids.add(current) - return ids + return ids def main() -> None: - generated = extract_msgids(sys.argv[1]) - committed = extract_msgids(sys.argv[2]) + generated = extract_msgids(sys.argv[1]) + committed = extract_msgids(sys.argv[2]) - missing = sorted(generated - committed) + missing = sorted(generated - committed) - if missing: - print('::error::New tr() strings not in base.pot - run locales_generator.sh:') - for s in missing: - print(f' {s}') - sys.exit(1) + if missing: + print('::error::New tr() strings not in base.pot - run locales_generator.sh:') + for s in missing: + print(f' {s}') + sys.exit(1) - print('All tr() strings are present in base.pot') + print('All tr() strings are present in base.pot') if __name__ == '__main__': - main() + main() diff --git a/scripts/pot_tools.py b/scripts/pot_tools.py index 35bd051beb..aec7374e06 100755 --- a/scripts/pot_tools.py +++ b/scripts/pot_tools.py @@ -2,9 +2,9 @@ """Tools for managing base.pot: find and add missing translatable strings. Usage (from repo root): - scripts/pot_tools.py stats - scripts/pot_tools.py list - scripts/pot_tools.py add_missing [--dry-run] + scripts/pot_tools.py stats + scripts/pot_tools.py list + scripts/pot_tools.py add_missing [--dry-run] Requires: gettext (xgettext) installed. """ @@ -22,124 +22,130 @@ def extract_msgids(path: Path) -> set[str]: - content = path.read_text() - ids: set[str] = set() - current: str | None = None + content = path.read_text() + ids: set[str] = set() + current: str | None = None - for line in content.splitlines(): - if line.startswith('msgid '): - m = re.search(r'"(.*)"', line) - current = m.group(1) if m else '' - elif current is not None and line.startswith('"'): - m = re.search(r'"(.*)"', line) - if m: - current += m.group(1) - else: - if current is not None and current: - ids.add(current) - current = None + for line in content.splitlines(): + if line.startswith('msgid '): + m = re.search(r'"(.*)"', line) + current = m.group(1) if m else '' + elif current is not None and line.startswith('"'): + m = re.search(r'"(.*)"', line) + if m: + current += m.group(1) + else: + if current is not None and current: + ids.add(current) + current = None - if current: - ids.add(current) + if current: + ids.add(current) - return ids + return ids def generate_fresh_pot() -> Path: - fd, tmp_path = tempfile.mkstemp(suffix='.pot') - os.close(fd) - tmp = Path(tmp_path) - py_files = sorted(str(p) for p in ARCHINSTALL_DIR.rglob('*.py')) - - subprocess.run( - ['xgettext', '--no-location', '--omit-header', - '--keyword=tr', '-d', 'base', '-o', str(tmp)] + py_files, - check=True, - capture_output=True, - ) - return tmp + fd, tmp_path = tempfile.mkstemp(suffix='.pot') + os.close(fd) + tmp = Path(tmp_path) + py_files = sorted(str(p) for p in ARCHINSTALL_DIR.rglob('*.py')) + + cmd = [ + 'xgettext', + '--no-location', + '--omit-header', + '--keyword=tr', + '-d', + 'base', + '-o', + str(tmp), + ] + py_files + + subprocess.run(cmd, check=True, capture_output=True) + return tmp def get_missing(fresh_pot: Path) -> set[str]: - generated = extract_msgids(fresh_pot) - committed = extract_msgids(BASE_POT) - return generated - committed + generated = extract_msgids(fresh_pot) + committed = extract_msgids(BASE_POT) + return generated - committed def cmd_stats() -> None: - fresh_pot = generate_fresh_pot() - try: - generated = extract_msgids(fresh_pot) - committed = extract_msgids(BASE_POT) - missing = generated - committed + fresh_pot = generate_fresh_pot() + try: + generated = extract_msgids(fresh_pot) + committed = extract_msgids(BASE_POT) + missing = generated - committed - print(f'Code: {len(generated)} translatable strings') - print(f'base.pot: {len(committed)} msgids') - print(f' Missing: {len(missing)}') - finally: - fresh_pot.unlink(missing_ok=True) + print(f'Code: {len(generated)} translatable strings') + print(f'base.pot: {len(committed)} msgids') + print(f' Missing: {len(missing)}') + finally: + fresh_pot.unlink(missing_ok=True) def cmd_list() -> None: - fresh_pot = generate_fresh_pot() - try: - missing = sorted(get_missing(fresh_pot)) + fresh_pot = generate_fresh_pot() + try: + missing = sorted(get_missing(fresh_pot)) - if missing: - print(f'=== MISSING ({len(missing)}): in code but not in base.pot ===') - for s in missing: - print(f' + {s}') - else: - print('No missing strings') - finally: - fresh_pot.unlink(missing_ok=True) + if missing: + print(f'=== MISSING ({len(missing)}): in code but not in base.pot ===') + for s in missing: + print(f' + {s}') + else: + print('No missing strings') + finally: + fresh_pot.unlink(missing_ok=True) def cmd_add_missing(dry_run: bool = False) -> None: - fresh_pot = generate_fresh_pot() - try: - missing = sorted(get_missing(fresh_pot)) + fresh_pot = generate_fresh_pot() + try: + missing = sorted(get_missing(fresh_pot)) - if not missing: - print('No missing strings, base.pot is up to date') - return + if not missing: + print('No missing strings, base.pot is up to date') + return - print(f'Adding {len(missing)} missing string(s)') - for s in missing: - print(f' + {s}') + print(f'Adding {len(missing)} missing string(s)') + for s in missing: + print(f' + {s}') - if dry_run: - print('(dry-run, no changes written)') - return + if dry_run: + print('(dry-run, no changes written)') + return - with open(BASE_POT, 'a') as f: - for s in missing: - if '{' in s: - f.write('\n#, python-brace-format') - f.write(f'\nmsgid "{s}"\nmsgstr ""\n') + with open(BASE_POT, 'a') as f: + for s in missing: + if '{' in s: + f.write('\n#, python-brace-format') + f.write(f'\nmsgid "{s}"\nmsgstr ""\n') - print(f'Done. Added to {BASE_POT}') - finally: - fresh_pot.unlink(missing_ok=True) + print(f'Done. Added to {BASE_POT}') + finally: + fresh_pot.unlink(missing_ok=True) def main() -> None: - if len(sys.argv) < 2 or sys.argv[1] in ('-h', '--help'): - print('Usage: pot_tools.py {stats|list|add_missing} [--dry-run]') - sys.exit(0) - - cmd = sys.argv[1] - if cmd == 'stats': - cmd_stats() - elif cmd == 'list': - cmd_list() - elif cmd == 'add_missing': - dry_run = '--dry-run' in sys.argv - cmd_add_missing(dry_run) - else: - print(f'Unknown command: {cmd}') - sys.exit(1) + if len(sys.argv) < 2 or sys.argv[1] in ('-h', '--help'): + print('Usage: pot_tools.py {stats|list|add_missing} [--dry-run]') + sys.exit(0) + + cmd = sys.argv[1] + if cmd == 'stats': + cmd_stats() + elif cmd == 'list': + cmd_list() + elif cmd == 'add_missing': + dry_run = '--dry-run' in sys.argv + cmd_add_missing(dry_run) + else: + print(f'Unknown command: {cmd}') + sys.exit(1) if __name__ == '__main__': - main() + main()