schon/engine/core/management/commands/check_translated.py
Egor fureunoir Gorbunov dc7f8be926 Features: 1) None;
Fixes: 1) Add `# ty: ignore` comments to suppress type errors in multiple files; 2) Correct method argument annotations and definitions to align with type hints; 3) Fix cases of invalid or missing imports and unresolved attributes;

Extra: Refactor method definitions to use tuple-based method declarations; replace custom type aliases with `Any`; improve caching utility and error handling logic in utility scripts.
2025-12-19 16:43:39 +03:00

173 lines
5.8 KiB
Python

import contextlib
import os
import re
from argparse import ArgumentParser
from tempfile import NamedTemporaryFile
from typing import Any
import polib
from django.apps import apps
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from engine.core.management.commands import TRANSLATABLE_APPS, RootDirectory
# Patterns to identify placeholders
PLACEHOLDER_REGEXES = [
re.compile(r"\{[^}]+"), # {name}, {type(instance)!s}, etc.
re.compile(r"%\([^)]+\)[sd]"), # %(verbose_name)s, %(count)d
]
def extract_placeholders(text: str) -> set[str]:
"""
Extract all placeholders from the given text.
"""
phs: list[str] = []
for rx in PLACEHOLDER_REGEXES:
phs.extend(rx.findall(text))
return set(phs)
def load_po_sanitized(path: str) -> polib.POFile:
"""
Load a .po file via polib, sanitizing on parse errors.
Raises CommandError if still unparsable.
"""
with contextlib.suppress(Exception):
return polib.pofile(path)
# read raw text
try:
with open(path, encoding="utf-8") as f:
text = f.read()
except OSError as e:
raise CommandError(f"{path}: cannot read file ({e})") from e
# fix fuzzy flags and empty header entries
text = re.sub(r"^#,(?!\s)", "#, ", text, flags=re.MULTILINE)
parts = text.split("\n\n", 1)
header = parts[0]
rest = parts[1] if len(parts) > 1 else ""
rest = re.sub(r"^msgid \"\"\s*\nmsgstr \"\"\s*\n?", "", rest, flags=re.MULTILINE)
sanitized = header + "\n\n" + rest
tmp = NamedTemporaryFile(mode="w+", delete=False, suffix=".po", encoding="utf-8") # noqa: SIM115
try:
tmp.write(sanitized)
tmp.flush()
tmp.close()
return polib.pofile(tmp.name)
except Exception as e:
raise CommandError(f"{path}: syntax error after sanitization ({e})") from e
finally:
with contextlib.suppress(OSError):
os.unlink(tmp.name)
class Command(BaseCommand):
help = "Scan target-language .po files and report any placeholder mismatches, grouped by app."
def add_arguments(self, parser: ArgumentParser) -> None:
parser.add_argument(
"-l",
"--language",
dest="target_languages",
action="append",
required=True,
metavar="LANG",
help="Locale code(s) to scan, e.g. de-DE, fr-FR",
)
parser.add_argument(
"-a",
"--app",
dest="target_apps",
action="append",
required=True,
metavar="APP",
help="App label(s) to scan, e.g. core, payments",
)
parser.add_argument(
"-p",
"--path",
dest="root_path",
required=False,
metavar="ROOT_PATH",
help="Root path prefix to adjust file links",
)
def handle(self, *args: Any, **options: Any) -> None:
langs: list[str] = options.get("target_languages", [])
if "ALL" in langs:
langs = list(dict(settings.LANGUAGES).keys())
apps_to_scan: set[str] = set(options["target_apps"])
if "ALL" in apps_to_scan:
apps_to_scan = set(TRANSLATABLE_APPS)
root_path: str = options.get("root_path") or "/app/"
configs = list(apps.get_app_configs())
# noinspection PyTypeChecker
configs.append(RootDirectory())
errors = 0
for app_conf in configs:
if app_conf.label not in apps_to_scan:
continue
# Collect issues per app across all languages
app_issues: list[str] = []
for lang in langs:
loc = lang.replace("-", "_")
po_path = os.path.join(
app_conf.path, "locale", loc, "LC_MESSAGES", "django.po"
)
if not os.path.exists(po_path):
continue
try:
po = load_po_sanitized(po_path)
except CommandError:
continue
# Collect lines for this language
lang_issues: list[str] = []
for entry in po:
if not entry.msgid or not entry.msgstr or entry.obsolete:
continue
src_ph = extract_placeholders(entry.msgid)
dst_ph = extract_placeholders(entry.msgstr)
missing = src_ph - dst_ph
extra = dst_ph - src_ph
if missing or extra:
line_no = entry.linenum or "?"
display = po_path.replace("/app/", root_path)
if "\\" in root_path:
display = display.replace("/", "\\")
lang_issues.append(
f" {display}:{line_no}: missing={sorted(missing)} extra={sorted(extra)}"
)
if lang_issues:
# Header for language with issues
app_issues.append(f"{lang}")
app_issues.extend(lang_issues)
# Output per app
if app_issues:
self.stdout.write(f"→ App: {app_conf.label}")
for line in app_issues:
errors += 1
self.stdout.write(line)
self.stdout.write("")
else:
# No issues in any language for this app
self.stdout.write(
self.style.SUCCESS(
f"App {app_conf.label} has no placeholder issues."
)
)
self.stdout.write("")
self.stdout.write(self.style.SUCCESS("Done scanning."))
if errors:
exit(1)