Public Access
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
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>
228 lines
8.3 KiB
Python
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()
|