Coverage for app/venv/lib/python3.14/site-packages/weblate/addons/gettext.py: 22%

214 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-07 07:15 +0000

1# Copyright © Michal Čihař <michal@weblate.org> 

2# 

3# SPDX-License-Identifier: GPL-3.0-or-later 

4 

5from __future__ import annotations 

6 

7import os 

8from typing import TYPE_CHECKING, ClassVar 

9 

10from django.core.management.utils import find_command 

11from django.utils.translation import gettext_lazy 

12 

13from weblate.addons.base import BaseAddon, UpdateBaseAddon 

14from weblate.addons.events import AddonEvent 

15from weblate.addons.forms import GenerateMoForm 

16from weblate.formats.base import UpdateError 

17from weblate.formats.exporters import MoExporter 

18from weblate.utils.state import STATE_FUZZY, STATE_TRANSLATED 

19 

20if TYPE_CHECKING: 20 ↛ 21line 20 didn't jump to line 21 because the condition on line 20 was never true

21 from collections.abc import Generator 

22 

23 from weblate.addons.base import CompatDict 

24 from weblate.auth.models import User 

25 from weblate.trans.models import Component, Translation 

26 

27 

28class GettextBaseAddon(BaseAddon): 

29 compat: ClassVar[CompatDict] = {"file_format": {"po", "po-mono"}} 

30 

31 

32class GenerateMoAddon(GettextBaseAddon): 

33 events: ClassVar[set[AddonEvent]] = { 

34 AddonEvent.EVENT_PRE_COMMIT, 

35 } 

36 name = "weblate.gettext.mo" 

37 verbose = gettext_lazy("Generate MO files") 

38 description = gettext_lazy( 

39 "Automatically generates a MO file for every changed PO file." 

40 ) 

41 settings_form = GenerateMoForm 

42 

43 def pre_commit( 

44 self, 

45 translation: Translation, 

46 author: str, 

47 store_hash: bool, 

48 activity_log_id: int | None = None, 

49 ) -> None: 

50 exporter = MoExporter(translation=translation) 

51 

52 if self.instance.configuration.get("fuzzy"): 

53 state = STATE_FUZZY 

54 else: 

55 state = STATE_TRANSLATED 

56 units = translation.unit_set.filter(state__gte=state) 

57 

58 exporter.add_units(units.prefetch_full()) 

59 

60 template = self.instance.configuration.get("path") 

61 if not template: 

62 template = "{{ filename|stripext }}.mo" 

63 

64 output = self.render_repo_filename(template, translation) 

65 if not output: 

66 return 

67 

68 with open(output, "wb") as handle: 

69 handle.write(exporter.serialize()) 

70 translation.addon_commit_files.append(output) 

71 

72 

73class UpdateLinguasAddon(GettextBaseAddon): 

74 events: ClassVar[set[AddonEvent]] = { 

75 AddonEvent.EVENT_POST_ADD, 

76 AddonEvent.EVENT_DAILY, 

77 } 

78 name = "weblate.gettext.linguas" 

79 verbose = gettext_lazy("Update LINGUAS file") 

80 description = gettext_lazy( 

81 "Updates the LINGUAS file when a new translation is added." 

82 ) 

83 

84 @staticmethod 

85 def get_linguas_path(component: Component) -> str: 

86 base = component.get_new_base_filename() 

87 if not base: 

88 base = os.path.join( 

89 component.full_path, component.filemask.replace("*", "x") 

90 ) 

91 return os.path.join(os.path.dirname(base), "LINGUAS") 

92 

93 @classmethod 

94 def can_install(cls, component: Component, user: User | None) -> bool: 

95 if not super().can_install(component, user): 

96 return False 

97 path = cls.get_linguas_path(component) 

98 return bool(path) and os.path.exists(path) 

99 

100 @staticmethod 

101 def update_linguas(lines: list[str], codes: set[str]) -> tuple[bool, list[str]]: 

102 changed = False 

103 remove = [] 

104 

105 for i, line in enumerate(lines): 

106 # Split at comment and strip whitespace 

107 stripped = line.split("#", 1)[0].strip() 

108 # Comment/blank lines 

109 if not stripped: 

110 continue 

111 # Languages in one line 

112 if " " in stripped: 

113 expected = " ".join(sorted(codes)) 

114 if stripped != expected: 

115 lines[i] = expected + "\n" 

116 changed = True 

117 codes = set() 

118 break 

119 # Language is already there 

120 if stripped in codes: 

121 codes.remove(stripped) 

122 else: 

123 remove.append(i) 

124 

125 # Remove no longer present codes 

126 if remove: 

127 for i in reversed(remove): 

128 del lines[i] 

129 changed = True 

130 

131 # Add missing codes 

132 if codes: 

133 lines.extend(f"{code}\n" for code in codes) 

134 changed = True 

135 

136 return changed, lines 

137 

138 def sync_linguas(self, component: Component, path: str) -> bool: 

139 with open(path) as handle: 

140 lines = handle.readlines() 

141 

142 codes = set( 

143 component.translation_set.exclude( 

144 language=component.source_language 

145 ).values_list("language_code", flat=True) 

146 ) 

147 

148 changed, lines = self.update_linguas(lines, codes) 

149 

150 if changed: 

151 with open(path, "w") as handle: 

152 handle.writelines(lines) 

153 

154 return changed 

155 

156 def post_add( 

157 self, translation: Translation, activity_log_id: int | None = None 

158 ) -> None: 

159 with translation.component.repository.lock: 

160 path = self.get_linguas_path(translation.component) 

161 if self.sync_linguas(translation.component, path): 

162 translation.addon_commit_files.append(path) 

163 

164 def daily(self, component: Component, activity_log_id: int | None = None) -> None: 

165 with component.repository.lock: 

166 path = self.get_linguas_path(component) 

167 if self.sync_linguas(component, path): 

168 self.commit_and_push(component, [path]) 

169 

170 

171class UpdateConfigureAddon(GettextBaseAddon): 

172 events: ClassVar[set[AddonEvent]] = { 

173 AddonEvent.EVENT_POST_ADD, 

174 AddonEvent.EVENT_DAILY, 

175 } 

176 name = "weblate.gettext.configure" 

177 verbose = gettext_lazy('Update ALL_LINGUAS variable in the "configure" file') 

178 description = gettext_lazy( 

179 'Updates the ALL_LINGUAS variable in "configure", ' 

180 '"configure.in" or "configure.ac" files, when a new translation is added.' 

181 ) 

182 

183 @staticmethod 

184 def get_configure_paths(component: Component) -> Generator[str]: 

185 base = component.full_path 

186 for name in ("configure", "configure.in", "configure.ac"): 

187 path = os.path.join(base, name) 

188 if os.path.exists(path): 

189 yield path 

190 

191 @classmethod 

192 def can_install(cls, component: Component, user: User | None) -> bool: 

193 if not super().can_install(component, user): 

194 return False 

195 for name in cls.get_configure_paths(component): 

196 try: 

197 with open(name) as handle: 

198 if 'ALL_LINGUAS="' in handle.read(): 

199 return True 

200 except UnicodeDecodeError: 

201 continue 

202 return False 

203 

204 def sync_linguas(self, component: Component, paths: list[str]) -> bool: 

205 added = False 

206 codes = " ".join( 

207 component.translation_set.exclude(language_id=component.source_language_id) 

208 .values_list("language_code", flat=True) 

209 .order_by("language_code") 

210 ) 

211 expected = f'ALL_LINGUAS="{codes}"\n' 

212 for path in paths: 

213 with open(path) as handle: 

214 lines = handle.readlines() 

215 

216 for i, line in enumerate(lines): 

217 stripped = line.strip() 

218 # Comment 

219 if stripped.startswith("#"): 

220 continue 

221 if not stripped.startswith('ALL_LINGUAS="'): 

222 continue 

223 if line != expected: 

224 lines[i] = expected 

225 added = True 

226 

227 if added: 

228 with open(path, "w") as handle: 

229 handle.writelines(lines) 

230 

231 return added 

232 

233 def post_add( 

234 self, translation: Translation, activity_log_id: int | None = None 

235 ) -> None: 

236 with translation.component.repository.lock: 

237 paths = list(self.get_configure_paths(translation.component)) 

238 if self.sync_linguas(translation.component, paths): 

239 translation.addon_commit_files.extend(paths) 

240 

241 def daily(self, component: Component, activity_log_id: int | None = None) -> None: 

242 with component.repository.lock: 

243 paths = list(self.get_configure_paths(component)) 

244 if self.sync_linguas(component, paths): 

245 self.commit_and_push(component, paths) 

246 

247 

248class MsgmergeAddon(GettextBaseAddon, UpdateBaseAddon): 

249 name = "weblate.gettext.msgmerge" 

250 verbose = gettext_lazy("Update PO files to match POT (msgmerge)") 

251 description = gettext_lazy( 

252 'Updates all PO files (as configured by "File mask") to match the ' 

253 'POT file (as configured by "Template for new translations") using msgmerge.' 

254 ) 

255 alert = "MsgmergeAddonError" 

256 compat: ClassVar[CompatDict] = {"file_format": {"po"}} 

257 

258 @classmethod 

259 def can_install(cls, component: Component, user: User | None) -> bool: 

260 if find_command("msgmerge") is None: 

261 return False 

262 return super().can_install(component, user) 

263 

264 def update_translations(self, component: Component, previous_head: str) -> None: 

265 # Run always when there is an alerts, there is a chance that 

266 # the update clears it. 

267 repository = component.repository 

268 if previous_head and not component.alert_set.filter(name=self.alert).exists(): 

269 changes = repository.list_changed_files( 

270 repository.ref_to_remote.format(previous_head) 

271 ) 

272 if component.new_base not in changes: 

273 component.log_info( 

274 "%s addon skipped, new base was not updated in %s..%s", 

275 self.name, 

276 previous_head, 

277 repository.last_revision, 

278 ) 

279 return 

280 template = component.get_new_base_filename() 

281 if not template or not os.path.exists(template): 

282 self.alerts.append( 

283 { 

284 "addon": self.name, 

285 "command": "msgmerge", 

286 "output": template, 

287 "error": "Template for new translations not found", 

288 } 

289 ) 

290 self.trigger_alerts(component) 

291 component.log_info("%s addon skipped, new base was not found", self.name) 

292 return 

293 args = component.file_format_cls.get_msgmerge_args(component) 

294 for translation in component.translation_set.iterator(): 

295 filename = translation.get_filename() 

296 if ( 

297 (translation.is_source and not translation.is_template) 

298 or not filename 

299 or not os.path.exists(filename) 

300 ): 

301 continue 

302 try: 

303 component.file_format_cls.update_bilingual( 

304 filename, template, args=args 

305 ) 

306 except UpdateError as error: 

307 self.alerts.append( 

308 { 

309 "addon": self.name, 

310 "command": error.cmd, 

311 "output": str(error.output), 

312 "error": str(error), 

313 } 

314 ) 

315 component.log_info("%s addon failed: %s", self.name, error) 

316 self.trigger_alerts(component) 

317 

318 def commit_and_push( 

319 self, 

320 component: Component, 

321 files: list[str] | None = None, 

322 skip_push: bool = False, 

323 ) -> bool: 

324 if super().commit_and_push(component, files=files, skip_push=skip_push): 

325 component.create_translations() 

326 return True 

327 return False 

328 

329 

330class GettextAuthorComments(GettextBaseAddon): 

331 events: ClassVar[set[AddonEvent]] = { 

332 AddonEvent.EVENT_PRE_COMMIT, 

333 } 

334 name = "weblate.gettext.authors" 

335 verbose = gettext_lazy("Contributors in comment") 

336 description = gettext_lazy( 

337 "Updates the comment part of the PO file header to include contributor names " 

338 "and years of contributions." 

339 ) 

340 

341 def pre_commit( 

342 self, 

343 translation: Translation, 

344 author: str, 

345 store_hash: bool, 

346 activity_log_id: int | None = None, 

347 ) -> None: 

348 if "noreply@weblate.org" in author: 

349 return 

350 if "<" in author: 

351 name, email = author.split("<") 

352 name = name.strip() 

353 email = email.rstrip(">") 

354 else: 

355 name = author 

356 email = None 

357 

358 translation.store.store.updatecontributor(name, email) 

359 translation.store.save() 

360 if store_hash: 

361 translation.store_hash()