199 lines
7.0 KiB
Python
199 lines
7.0 KiB
Python
#!/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
|