Coverage for app/venv/lib/python3.14/site-packages/weblate/checks/consistency.py: 42%
168 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
7from collections import defaultdict
8from functools import reduce
9from typing import TYPE_CHECKING, ClassVar, Literal
11from django.db.models import Count, Prefetch, Q, Value
12from django.db.models.functions import MD5, Lower
13from django.utils.translation import gettext, gettext_lazy, ngettext
15from weblate.checks.base import BatchCheckMixin, TargetCheck
16from weblate.trans.actions import ACTIONS_REVERTABLE, ActionEvents
17from weblate.trans.util import split_plural
18from weblate.utils.html import format_html_join_comma
19from weblate.utils.state import STATE_TRANSLATED
21if TYPE_CHECKING: 21 ↛ 22line 21 didn't jump to line 22 because the condition on line 21 was never true
22 from collections.abc import Iterable
24 from weblate.trans.models import Change, Component, Unit
26 from .base import FixupType
29class PluralsCheck(TargetCheck):
30 """Check for incomplete plural forms."""
32 check_id = "plurals"
33 name = gettext_lazy("Missing plurals")
34 description = gettext_lazy("Some plural forms are untranslated.")
36 def should_skip(self, unit: Unit):
37 if unit.translation.component.is_multivalue:
38 return True
39 return super().should_skip(unit)
41 def check_target_unit(self, sources: list[str], targets: list[str], unit: Unit):
42 # Is this plural?
43 if len(sources) == 1:
44 return False
45 # Is at least something translated?
46 if targets == len(targets) * [""]:
47 return False
48 # Check for empty translation
49 return "" in targets
51 def check_single(self, source: str, target: str, unit: Unit) -> bool:
52 """Target strings are checked in check_target_unit."""
53 return False
56class SamePluralsCheck(TargetCheck):
57 """Check for same plural forms."""
59 check_id = "same-plurals"
60 name = gettext_lazy("Same plurals")
61 description = gettext_lazy("Some plural forms are translated in the same way.")
63 def check_target_unit(self, sources: list[str], targets: list[str], unit: Unit):
64 # Is this plural?
65 if len(sources) == 1 or len(targets) == 1:
66 return False
67 if not targets[0]:
68 return False
69 return len(set(targets)) == 1
71 def check_single(self, source: str, target: str, unit: Unit) -> bool:
72 """Target strings are checked in check_target_unit."""
73 return False
76class ConsistencyCheck(TargetCheck, BatchCheckMixin):
77 """Check for inconsistent translations."""
79 check_id = "inconsistent"
80 name = gettext_lazy("Inconsistent")
81 description = gettext_lazy(
82 "This string has more than one translation in this project "
83 "or is untranslated in some components."
84 )
85 ignore_untranslated = False
86 propagates = "source"
87 batch_project_wide = True
88 skip_suggestions = True
90 def check_target_unit(
91 self, sources: list[str], targets: list[str], unit: Unit
92 ) -> bool:
93 component = unit.translation.component
94 if not component.allow_translation_propagation:
95 return False
97 # Use last result if checks are batched
98 if component.batch_checks: 98 ↛ 101line 98 didn't jump to line 101 because the condition on line 98 was always true
99 return self.handle_batch(unit, component)
101 others = unit.propagated_units.exclude(target=unit.target)
102 if not unit.translated:
103 # Look only for translated units
104 others = others.filter(state__gte=STATE_TRANSLATED)
105 return others.exists()
107 def check_single(self, source: str, target: str, unit: Unit) -> bool:
108 """Target strings are checked in check_target_unit."""
109 return False
111 def check_component(self, component: Component) -> Iterable[Unit]:
112 from weblate.trans.models import Unit
114 units = Unit.objects.filter(
115 translation__component__project=component.project,
116 translation__component__allow_translation_propagation=True,
117 )
119 # List strings with different targets
120 # Limit this to 100 strings, otherwise the resulting query is way too complex
121 matches = (
122 units.values("id_hash", "translation__plural_id")
123 .annotate(Count("target", distinct=True))
124 .filter(target__count__gt=1)
125 .order_by("id_hash")[:100]
126 )
128 if not matches: 128 ↛ 131line 128 didn't jump to line 131 because the condition on line 128 was always true
129 return []
131 return (
132 units.filter(
133 reduce(
134 lambda x, y: x
135 | (
136 Q(id_hash=y["id_hash"])
137 & Q(translation__plural_id=y["translation__plural_id"])
138 ),
139 matches,
140 Q(),
141 )
142 )
143 .prefetch()
144 .prefetch_bulk()
145 )
148class ReusedCheck(TargetCheck, BatchCheckMixin):
149 """
150 Check for reused translations.
152 This is skipped for languages with a single plural form as that causes too
153 many false positives, see https://github.com/WeblateOrg/weblate/issues/9450
154 """
156 check_id = "reused"
157 name = gettext_lazy("Reused translation")
158 description = gettext_lazy("Different strings are translated the same.")
159 propagates = "target"
160 batch_project_wide = True
161 skip_suggestions = True
163 def should_skip(self, unit: Unit):
164 if unit.translation.plural.number <= 1 or not any(unit.get_target_plurals()):
165 return True
166 return super().should_skip(unit)
168 def check_target_unit(self, sources: list[str], targets: list[str], unit: Unit):
169 from weblate.trans.models import Unit
171 translation = unit.translation
172 component = translation.component
174 # Use last result if checks are batched
175 if component.batch_checks:
176 return self.handle_batch(unit, component)
178 return Unit.objects.same_target(unit).exists()
180 def get_description(self, check_obj):
181 from weblate.trans.models import Unit
183 other_sources = (
184 Unit.objects.same_target(check_obj.unit)
185 .values_list("source", flat=True)
186 .distinct()
187 )
189 return ngettext(
190 "Other source string: %s", "Other source strings: %s", len(other_sources)
191 ) % format_html_join_comma(
192 "{}", ((gettext("“%s”") % source,) for source in other_sources)
193 )
195 def check_single(self, source: str, target: str, unit: Unit) -> bool:
196 """Target strings are checked in check_target_unit."""
197 return False
199 def check_component(self, component: Component) -> Iterable[Unit]:
200 from weblate.trans.models import Unit
202 units = Unit.objects.filter(
203 translation__component__project=component.project,
204 translation__component__allow_translation_propagation=True,
205 state__gte=STATE_TRANSLATED,
206 )
207 # Lower has no effect here, but we want to utilize index
208 units = units.exclude(target__lower__md5=MD5(Value("")))
210 # List strings with different sources
211 # Limit this to 20 strings, otherwise the resulting query is too slow
212 # Use ordering to make the limit deterministic
213 matches = (
214 units.values("target", "translation__plural_id")
215 .annotate(source__count=Count("source", distinct=True))
216 .filter(source__count__gt=1)
217 .order_by("target__lower__md5")[:20]
218 )
220 if not matches:
221 return
223 result = (
224 units.filter(
225 reduce(
226 lambda x, y: x
227 | (
228 Q(target__lower__md5=MD5(Lower(Value(y["target"]))))
229 & Q(target=y["target"])
230 & Q(translation__plural_id=y["translation__plural_id"])
231 ),
232 matches,
233 Q(),
234 )
235 )
236 .prefetch()
237 .prefetch_bulk()
238 )
240 # Filter out case differing source for case insensitive languages
241 found: dict[tuple[str, str], set[str]] = defaultdict(set)
242 remaining: list[tuple[str, Unit]] = []
243 for unit in result:
244 if not unit.translation.language.is_case_sensitive():
245 key = (unit.translation.language.code, unit.target)
246 lower_source = unit.source.lower()
247 found[key].add(lower_source)
248 remaining.append((key, unit))
249 else:
250 yield unit
252 for key, unit in remaining:
253 if len(found[key]) > 1:
254 yield unit
257class TranslatedCheck(TargetCheck, BatchCheckMixin):
258 """Check for inconsistent translations."""
260 check_id = "translated"
261 name = gettext_lazy("Has been translated")
262 description = gettext_lazy("This string has been translated in the past.")
263 ignore_untranslated = False
264 skip_suggestions = True
266 SOURCE_ACTIONS: ClassVar[set[ActionEvents]] = {
267 ActionEvents.SOURCE_CHANGE,
268 ActionEvents.MARKED_EDIT,
269 }
271 TRACK_ACTIONS: ClassVar[set[ActionEvents]] = ACTIONS_REVERTABLE | SOURCE_ACTIONS
273 def get_description(self, check_obj):
274 unit = check_obj.unit
275 target = self.check_target_unit(unit.source, unit.target, unit)
276 if not target:
277 return super().get_description(check_obj)
278 return gettext('Previous translation was "%s".') % target
280 def should_skip_change(self, change: Change, unit: Unit):
281 # Skip automatic translation entries adding needs editing string
282 return (
283 change.action == ActionEvents.AUTO
284 and change.details.get("state", STATE_TRANSLATED) < STATE_TRANSLATED
285 )
287 def should_break_changes(self, change: Change):
288 # Stop changes processing on source string change or on
289 # intentional marking as needing edit
290 return change.action in self.SOURCE_ACTIONS
292 def check_target_unit( # type: ignore[override]
293 self, sources: list[str], targets: list[str], unit: Unit
294 ) -> Literal[False] | str:
295 # TODO: this is type annotation hack, instead the check should have a proper return type
296 if unit.translated: 296 ↛ 297line 296 didn't jump to line 297 because the condition on line 296 was never true
297 return False
299 component = unit.translation.component
301 if component.batch_checks:
302 if self.handle_batch(unit, component): 302 ↛ 304line 302 didn't jump to line 304 because the condition on line 302 was never true
303 # This needs to be true-ish value
304 return "present"
305 return False
307 changes = unit.change_set.filter(action__in=self.TRACK_ACTIONS).order()
309 for change in changes: 309 ↛ 310line 309 didn't jump to line 310 because the loop on line 309 never started
310 if self.should_break_changes(change):
311 break
312 if self.should_skip_change(change, unit):
313 continue
314 if change.target and change.target != unit.target:
315 return change.target
317 return False
319 def check_single(self, source: str, target: str, unit: Unit) -> bool:
320 """Target strings are checked in check_target_unit."""
321 return False
323 def get_fixup(self, unit: Unit) -> Iterable[FixupType] | None:
324 target = self.check_target_unit(
325 unit.get_source_plurals(), unit.get_target_plurals(), unit
326 )
327 if not target:
328 return None
329 return [("plurals", split_plural(target))]
331 def check_component(self, component: Component) -> Iterable[Unit]:
332 from weblate.trans.models import Change, Unit
334 units = (
335 Unit.objects.filter(
336 translation__component=component,
337 change__action__in=self.TRACK_ACTIONS,
338 state__lt=STATE_TRANSLATED,
339 )
340 .prefetch_related(
341 Prefetch(
342 "change_set",
343 queryset=Change.objects.filter(
344 action__in=self.TRACK_ACTIONS,
345 ).order(),
346 to_attr="recent_consistency_changes",
347 )
348 )
349 .prefetch()
350 .prefetch_bulk()
351 )
353 for unit in units: 353 ↛ 354line 353 didn't jump to line 354 because the loop on line 353 never started
354 for change in unit.recent_consistency_changes:
355 if self.should_break_changes(change):
356 break
357 if self.should_skip_change(change, unit):
358 continue
359 if change.target:
360 yield unit