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

1"""Extracting the interface strings from the code into a gettext template. 

2 

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""" 

8 

9import os 

10import shutil 

11import subprocess 

12import tempfile 

13from dataclasses import dataclass 

14from pathlib import Path 

15 

16import polib 

17from django.conf import settings 

18from django.utils.translation import templatize 

19 

20EXTENSIONS = {".py", ".html", ".txt"} 

21IGNORED_DIRS = {"tests", "migrations", "node_modules", "__pycache__", "static", "static_src"} 

22TEMPLATE_SUFFIX = ".py" 

23 

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] 

37 

38 

39class ExtractionError(RuntimeError): 

40 pass 

41 

42 

43@dataclass(frozen=True) 

44class SourceRoot: 

45 """A directory to scan, and the name its files get in the template.""" 

46 

47 path: Path 

48 label: str 

49 

50 

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 

58 

59 

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()}" 

67 

68 

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