Files
devx/src/devx/ci/check_translations.py
T
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> c62b168b25
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / configure-repo (push) Successful in 15s
Post-merge / vikunja (push) Successful in 22s
Post-merge / release (push) Successful in 23s
Post-merge / publish (push) Has been skipped
Post-merge / sync-wiki (push) Successful in 30s
Post-merge / badges (push) Failing after 30s
DEVX-116: chore: update grm package name references
Update hardcoded path and docstring examples from
`gitea_runner_manager` to `grm` after the package rename in grm PR #203.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-06 15:51:10 +02:00

228 lines
8.3 KiB
Python

#!/usr/bin/env python3
"""Check translation files for gaps, dead keys, and missing languages.
Validates translation files against the Python source code that uses them.
By default, checks ``src/devx/translations.json`` against keys used in
``src/devx/**/*.py``. Additional translation sets can be checked by
passing ``--translations`` flags (each pointing to a JSON file; the
source directory is inferred as the parent of the translations file).
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 6 supported
languages (en, bg, de, ru, zh, pl). This is an error — all supported languages
must have translations for every key.
Usage::
python3 -m devx.ci.check_translations
python3 -m devx.ci.check_translations --translations path/to/translations.json
"""
from __future__ import annotations
import ast
import json
import sys
from dataclasses import dataclass, field
from pathlib import Path
import click
REPO_ROOT = Path.cwd()
SUPPORTED_LANGS = ("en", "bg", "de", "ru", "zh", "pl")
# Default translation set: look for translations.json in the current repo
DEFAULT_TRANS_FILE = REPO_ROOT / "src" / "devx" / "translations.json"
DEFAULT_SRC_DIR = REPO_ROOT / "src" / "devx"
# 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)
# Dynamic keys are common status strings used via _(variable) that
# can't be detected by AST scanning. Include them for all projects.
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.errors.append(f"Dead key in {name}: {key!r}")
# Check for missing languages — this is an error, not a warning.
# All supported languages must have translations for every key.
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.errors.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(
"--translations",
"translations",
multiple=True,
type=click.Path(exists=False, path_type=Path),
help="Path to a translations JSON file to check (can be repeated). Auto-detects by default.",
)
@click.option(
"--source-dir",
default=None,
help="Source directory to scan for _() calls (default: auto-detect).",
)
def main(translations: tuple[Path, ...], source_dir: str | None) -> None:
"""Check translation files for gaps, dead keys, and missing languages."""
results: list[TranslationCheckResult] = []
if not translations:
# Auto-detect translations file in the current repo
root = Path.cwd()
# Try common locations
candidates = [
root / "src" / "devx" / "translations.json",
root / "src" / "grm" / "translations.json",
]
# Also search for any translations.json in src/
for match in root.glob("src/*/translations.json"):
candidates.append(match)
found = False
for candidate in candidates:
if candidate.exists():
src_dir = Path(source_dir) if source_dir else candidate.parent
results.append(check_translation_set(candidate.parent.name, src_dir, candidate))
found = True
break
if not found:
# No translations file found — this repo doesn't use i18n
click.echo("PASS: No translations file found — skipping (repo does not use i18n).")
return
else:
for trans_file in translations:
# Infer source directory as the parent of the translations file
src_dir = Path(source_dir) if source_dir else trans_file.parent
name = trans_file.parent.name
results.append(check_translation_set(name, src_dir, trans_file))
has_errors = False
for result in results:
print_result(result)
if result.errors:
has_errors = True
click.echo()
if has_errors:
click.echo("FAIL: Translation check found errors.", err=True)
sys.exit(1)
click.echo("PASS: All translations are complete and up to date.")
if __name__ == "__main__": # pragma: no cover
main()