Coverage for baibu/localization/extraction.py: 100%
65 statements
« prev ^ index » next coverage.py v7.10.7, created at 2026-10-06 13:07 +0000
« prev ^ index » next coverage.py v7.10.7, created at 2026-10-06 13:07 +0000
1"""Extracting the interface strings from the code into a gettext template.
3This does what ``makemessages`` does for the ``django`` domain (templates are
4converted with Django's own ``templatize`` and read by GNU ``xgettext`` with
5the same keywords), but writes only to a temporary directory. The code
6directory can stay read-only and nothing is written into the repository.
7"""
9import os
10import shutil
11import subprocess
12import tempfile
13from dataclasses import dataclass
14from pathlib import Path
16import polib
17from django.conf import settings
18from django.utils.translation import templatize
20EXTENSIONS = {".py", ".html", ".txt"}
21IGNORED_DIRS = {"tests", "migrations", "node_modules", "__pycache__", "static", "static_src"}
22TEMPLATE_SUFFIX = ".py"
24# The same keywords makemessages passes to xgettext for the django domain.
25XGETTEXT_ARGS = [
26 "--language=Python",
27 "--from-code=UTF-8",
28 "--add-comments=Translators",
29 "--keyword=gettext_noop",
30 "--keyword=gettext_lazy",
31 "--keyword=ngettext_lazy:1,2",
32 "--keyword=pgettext:1c,2",
33 "--keyword=npgettext:1c,2,3",
34 "--keyword=pgettext_lazy:1c,2",
35 "--keyword=npgettext_lazy:1c,2,3",
36]
39class ExtractionError(RuntimeError):
40 pass
43@dataclass(frozen=True)
44class SourceRoot:
45 """A directory to scan, and the name its files get in the template."""
47 path: Path
48 label: str
51def default_roots() -> list[SourceRoot]:
52 base = Path(settings.BASE_DIR)
53 roots = [SourceRoot(Path(settings.APPS_DIR), "baibu"), SourceRoot(base / "config", "config")]
54 deployment_templates = Path(settings.DEPLOYMENT_DIR) / "templates"
55 if deployment_templates.is_dir():
56 roots.append(SourceRoot(deployment_templates, "deployment/templates"))
57 return roots
60def _source_files(root: SourceRoot):
61 for dirpath, dirnames, filenames in os.walk(root.path):
62 dirnames[:] = sorted(name for name in dirnames if name not in IGNORED_DIRS and not name.startswith("."))
63 for filename in sorted(filenames):
64 path = Path(dirpath) / filename
65 if path.suffix in EXTENSIONS and not filename.startswith("."):
66 yield path, f"{root.label}/{path.relative_to(root.path).as_posix()}"
69def extract(roots: list[SourceRoot] | None = None) -> polib.POFile:
70 """Return a template (.pot) with every translatable string under ``roots``."""
71 xgettext = shutil.which("xgettext")
72 if xgettext is None:
73 msg = "xgettext was not found. Install GNU gettext (the Docker image includes it)."
74 raise ExtractionError(msg)
75 roots = default_roots() if roots is None else roots
76 with tempfile.TemporaryDirectory(prefix="translation-sources-") as tmp:
77 work = Path(tmp)
78 names: list[str] = []
79 for root in roots:
80 for path, label in _source_files(root):
81 try:
82 content = path.read_text(encoding="utf-8")
83 except UnicodeDecodeError:
84 continue
85 name = label
86 if path.suffix != ".py":
87 content = templatize(content, origin=label)
88 name = label + TEMPLATE_SUFFIX
89 target = work / name
90 target.parent.mkdir(parents=True, exist_ok=True)
91 target.write_text(content, encoding="utf-8")
92 names.append(name)
93 (work / "files.txt").write_text("\n".join(names), encoding="utf-8")
94 result = subprocess.run( # noqa: S603 - fixed arguments, no shell
95 [xgettext, *XGETTEXT_ARGS, "--output=django.pot", "--files-from=files.txt"],
96 cwd=work,
97 capture_output=True,
98 text=True,
99 check=False,
100 )
101 if result.returncode != 0:
102 msg = f"xgettext failed: {result.stderr.strip()}"
103 raise ExtractionError(msg)
104 pot_path = work / "django.pot"
105 template = polib.pofile(str(pot_path)) if pot_path.exists() else polib.POFile()
106 for entry in template:
107 entry.occurrences = [
108 (path.removesuffix(TEMPLATE_SUFFIX) if path.endswith((".html.py", ".txt.py")) else path, line)
109 for path, line in entry.occurrences
110 ]
111 template.metadata = {
112 "Project-Id-Version": "interface",
113 "MIME-Version": "1.0",
114 "Content-Type": "text/plain; charset=UTF-8",
115 "Content-Transfer-Encoding": "8bit",
116 }
117 return template