#!/usr/bin/env python3 """Check translation files for gaps, dead keys, and missing languages. Validates two separate translation sets: 1. GRM tool translations: ``src/gitea_runner_manager/translations.json`` — keys used by ``src/gitea_runner_manager/*.py`` 2. CI/dev tool translations: ``scripts/translations.json`` — keys used by ``scripts/**/*.py`` Checks performed (all fail with exit code 1 on error): - **Missing keys**: a ``_()`` call in code has no entry in the corresponding translations file. - **Dead keys**: a key in a translations file is not used in any code. - **Missing languages**: a key exists but is missing one of the 5 supported languages (en, bg, de, ru, zh). Reported as a warning, not an error. Usage:: python3 scripts/ci/check_translations.py python3 scripts/ci/check_translations.py --strict # warnings are errors """ from __future__ import annotations import ast import json import sys from dataclasses import dataclass, field from pathlib import Path import click REPO_ROOT = Path(__file__).resolve().parent.parent.parent SUPPORTED_LANGS = ("en", "bg", "de", "ru", "zh") GRM_SRC_DIR = REPO_ROOT / "src" / "gitea_runner_manager" GRM_TRANS_FILE = GRM_SRC_DIR / "translations.json" CI_SRC_DIR = REPO_ROOT / "scripts" CI_TRANS_FILE = CI_SRC_DIR / "translations.json" # Functions that wrap _() and receive a translation key as first arg. # Their string-literal arguments should be treated as translation keys. _I18N_WRAPPERS = {"_handle_errors"} # Known dynamic keys used via _(variable) that can't be detected by AST. # These are status strings set as variable values and passed to _(). DYNAMIC_KEYS = {"completed", "pending", "in_progress", "failed", "active", "inactive", "unknown"} @dataclass class TranslationCheckResult: """Result of a translation check for one translation set.""" name: str src_dir: Path trans_file: Path used_keys: set[str] = field(default_factory=set) defined_keys: set[str] = field(default_factory=set) missing_keys: set[str] = field(default_factory=set) dead_keys: set[str] = field(default_factory=set) missing_langs: dict[str, list[str]] = field(default_factory=dict) errors: list[str] = field(default_factory=list) warnings: list[str] = field(default_factory=list) def extract_keys(filepath: Path) -> set[str]: """Extract translation keys from a Python file using AST. Detects: - ``_("key")`` calls with string-literal first argument - ``_handle_errors("key")`` and other wrapper calls (see ``_I18N_WRAPPERS``) """ try: tree = ast.parse(filepath.read_text(encoding="utf-8"), filename=str(filepath)) except SyntaxError: return set() keys: set[str] = set() for node in ast.walk(tree): if isinstance(node, ast.Call): func = node.func if ( isinstance(func, ast.Name) and func.id in ("_", *_I18N_WRAPPERS) and node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str) ): keys.add(node.args[0].value) return keys def collect_keys(src_dir: Path) -> set[str]: """Collect all translation keys from .py files in a directory tree. Also includes known dynamic keys (see ``DYNAMIC_KEYS``) that are used via ``_(variable)`` and can't be detected by AST scanning. """ keys: set[str] = set() for pyfile in src_dir.rglob("*.py"): if pyfile.name == "i18n.py": continue keys |= extract_keys(pyfile) # Add dynamic keys for the GRM source directory if src_dir == GRM_SRC_DIR: keys |= DYNAMIC_KEYS return keys def check_translation_set(name: str, src_dir: Path, trans_file: Path) -> TranslationCheckResult: """Check one translation set for gaps and dead keys.""" result = TranslationCheckResult(name=name, src_dir=src_dir, trans_file=trans_file) # Collect used keys from source code result.used_keys = collect_keys(src_dir) # Load defined keys from translations file if not trans_file.exists(): result.errors.append(f"Translations file not found: {trans_file}") return result translations = json.loads(trans_file.read_text(encoding="utf-8")) result.defined_keys = set(translations.keys()) # Check for missing keys (used in code but not in translations) result.missing_keys = result.used_keys - result.defined_keys for key in sorted(result.missing_keys): result.errors.append(f"Missing key in {name}: {key!r}") # Check for dead keys (in translations but not used in code) result.dead_keys = result.defined_keys - result.used_keys for key in sorted(result.dead_keys): result.warnings.append(f"Dead key in {name}: {key!r}") # Check for missing languages for key, langs in translations.items(): missing = [lang for lang in SUPPORTED_LANGS if lang not in langs] if missing: result.missing_langs[key] = missing result.warnings.append(f"Missing languages {missing} for key {key!r} in {name}") return result def print_result(result: TranslationCheckResult) -> None: """Print check results in a human-readable format.""" click.echo(f"\n=== {result.name} ===") click.echo(f" Source dir: {result.src_dir}") click.echo(f" Translations: {result.trans_file}") click.echo(f" Used keys: {len(result.used_keys)}") click.echo(f" Defined keys: {len(result.defined_keys)}") click.echo(f" Missing keys: {len(result.missing_keys)}") click.echo(f" Dead keys: {len(result.dead_keys)}") click.echo(f" Missing langs: {len(result.missing_langs)} keys") for err in result.errors: click.echo(f" ERROR: {err}", err=True) for warn in result.warnings: click.echo(f" WARN: {warn}", err=True) if not result.errors and not result.warnings: click.echo(" All good!") @click.command() @click.option("--strict", is_flag=True, default=False, help="Treat warnings as errors.") def main(strict: bool) -> None: """Check translation files for gaps, dead keys, and missing languages.""" results = [ check_translation_set("GRM tool", GRM_SRC_DIR, GRM_TRANS_FILE), check_translation_set("CI/dev tools", CI_SRC_DIR, CI_TRANS_FILE), ] has_errors = False has_warnings = False for result in results: print_result(result) if result.errors: has_errors = True if result.warnings: has_warnings = True click.echo() if has_errors: click.echo("FAIL: Translation check found errors.", err=True) sys.exit(1) if strict and has_warnings: click.echo("FAIL: Translation check found warnings (--strict mode).", err=True) sys.exit(1) if has_warnings: click.echo("PASS with warnings: Translation check passed (warnings present).") else: click.echo("PASS: All translations are complete and up to date.") if __name__ == "__main__": # pragma: no cover main() # pragma: no cover