Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/scripts/check_pot_freshness.py
Original file line number Diff line number Diff line change
@@ -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()
140 changes: 140 additions & 0 deletions .github/workflows/translation-check.yaml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion archinstall/lib/global_menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion archinstall/locales/base.pot
Original file line number Diff line number Diff line change
Expand Up @@ -1245,7 +1245,7 @@ msgid "Product"
msgstr ""

#, python-brace-format
msgid "Invalid configuration: {error}"
msgid "Invalid configuration: {}"
msgstr ""

msgid "Ready to install"
Expand Down
151 changes: 151 additions & 0 deletions scripts/pot_tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
#!/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'))

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


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()