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
« 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
5from __future__ import annotations
7import os
8from typing import TYPE_CHECKING, ClassVar
10from django.core.management.utils import find_command
11from django.utils.translation import gettext_lazy
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
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
23 from weblate.addons.base import CompatDict
24 from weblate.auth.models import User
25 from weblate.trans.models import Component, Translation
28class GettextBaseAddon(BaseAddon):
29 compat: ClassVar[CompatDict] = {"file_format": {"po", "po-mono"}}
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
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)
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)
58 exporter.add_units(units.prefetch_full())
60 template = self.instance.configuration.get("path")
61 if not template:
62 template = "{{ filename|stripext }}.mo"
64 output = self.render_repo_filename(template, translation)
65 if not output:
66 return
68 with open(output, "wb") as handle:
69 handle.write(exporter.serialize())
70 translation.addon_commit_files.append(output)
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 )
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")
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)
100 @staticmethod
101 def update_linguas(lines: list[str], codes: set[str]) -> tuple[bool, list[str]]:
102 changed = False
103 remove = []
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)
125 # Remove no longer present codes
126 if remove:
127 for i in reversed(remove):
128 del lines[i]
129 changed = True
131 # Add missing codes
132 if codes:
133 lines.extend(f"{code}\n" for code in codes)
134 changed = True
136 return changed, lines
138 def sync_linguas(self, component: Component, path: str) -> bool:
139 with open(path) as handle:
140 lines = handle.readlines()
142 codes = set(
143 component.translation_set.exclude(
144 language=component.source_language
145 ).values_list("language_code", flat=True)
146 )
148 changed, lines = self.update_linguas(lines, codes)
150 if changed:
151 with open(path, "w") as handle:
152 handle.writelines(lines)
154 return changed
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)
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])
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 )
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
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
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()
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
227 if added:
228 with open(path, "w") as handle:
229 handle.writelines(lines)
231 return added
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)
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)
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"}}
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)
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)
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
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 )
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
358 translation.store.store.updatecontributor(name, email)
359 translation.store.save()
360 if store_hash:
361 translation.store_hash()