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: list[Any], **options: dict[str, str | list[str]]) -> 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)