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

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

2# 

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

4 

5from __future__ import annotations 

6 

7from collections import defaultdict 

8from functools import reduce 

9from typing import TYPE_CHECKING, ClassVar, Literal 

10 

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 

14 

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 

20 

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 

23 

24 from weblate.trans.models import Change, Component, Unit 

25 

26 from .base import FixupType 

27 

28 

29class PluralsCheck(TargetCheck): 

30 """Check for incomplete plural forms.""" 

31 

32 check_id = "plurals" 

33 name = gettext_lazy("Missing plurals") 

34 description = gettext_lazy("Some plural forms are untranslated.") 

35 

36 def should_skip(self, unit: Unit): 

37 if unit.translation.component.is_multivalue: 

38 return True 

39 return super().should_skip(unit) 

40 

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 

50 

51 def check_single(self, source: str, target: str, unit: Unit) -> bool: 

52 """Target strings are checked in check_target_unit.""" 

53 return False 

54 

55 

56class SamePluralsCheck(TargetCheck): 

57 """Check for same plural forms.""" 

58 

59 check_id = "same-plurals" 

60 name = gettext_lazy("Same plurals") 

61 description = gettext_lazy("Some plural forms are translated in the same way.") 

62 

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 

70 

71 def check_single(self, source: str, target: str, unit: Unit) -> bool: 

72 """Target strings are checked in check_target_unit.""" 

73 return False 

74 

75 

76class ConsistencyCheck(TargetCheck, BatchCheckMixin): 

77 """Check for inconsistent translations.""" 

78 

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 

89 

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 

96 

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) 

100 

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

106 

107 def check_single(self, source: str, target: str, unit: Unit) -> bool: 

108 """Target strings are checked in check_target_unit.""" 

109 return False 

110 

111 def check_component(self, component: Component) -> Iterable[Unit]: 

112 from weblate.trans.models import Unit 

113 

114 units = Unit.objects.filter( 

115 translation__component__project=component.project, 

116 translation__component__allow_translation_propagation=True, 

117 ) 

118 

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 ) 

127 

128 if not matches: 128 ↛ 131line 128 didn't jump to line 131 because the condition on line 128 was always true

129 return [] 

130 

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 ) 

146 

147 

148class ReusedCheck(TargetCheck, BatchCheckMixin): 

149 """ 

150 Check for reused translations. 

151 

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

155 

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 

162 

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) 

167 

168 def check_target_unit(self, sources: list[str], targets: list[str], unit: Unit): 

169 from weblate.trans.models import Unit 

170 

171 translation = unit.translation 

172 component = translation.component 

173 

174 # Use last result if checks are batched 

175 if component.batch_checks: 

176 return self.handle_batch(unit, component) 

177 

178 return Unit.objects.same_target(unit).exists() 

179 

180 def get_description(self, check_obj): 

181 from weblate.trans.models import Unit 

182 

183 other_sources = ( 

184 Unit.objects.same_target(check_obj.unit) 

185 .values_list("source", flat=True) 

186 .distinct() 

187 ) 

188 

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 ) 

194 

195 def check_single(self, source: str, target: str, unit: Unit) -> bool: 

196 """Target strings are checked in check_target_unit.""" 

197 return False 

198 

199 def check_component(self, component: Component) -> Iterable[Unit]: 

200 from weblate.trans.models import Unit 

201 

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

209 

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 ) 

219 

220 if not matches: 

221 return 

222 

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 ) 

239 

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 

251 

252 for key, unit in remaining: 

253 if len(found[key]) > 1: 

254 yield unit 

255 

256 

257class TranslatedCheck(TargetCheck, BatchCheckMixin): 

258 """Check for inconsistent translations.""" 

259 

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 

265 

266 SOURCE_ACTIONS: ClassVar[set[ActionEvents]] = { 

267 ActionEvents.SOURCE_CHANGE, 

268 ActionEvents.MARKED_EDIT, 

269 } 

270 

271 TRACK_ACTIONS: ClassVar[set[ActionEvents]] = ACTIONS_REVERTABLE | SOURCE_ACTIONS 

272 

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 

279 

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 ) 

286 

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 

291 

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 

298 

299 component = unit.translation.component 

300 

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 

306 

307 changes = unit.change_set.filter(action__in=self.TRACK_ACTIONS).order() 

308 

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 

316 

317 return False 

318 

319 def check_single(self, source: str, target: str, unit: Unit) -> bool: 

320 """Target strings are checked in check_target_unit.""" 

321 return False 

322 

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))] 

330 

331 def check_component(self, component: Component) -> Iterable[Unit]: 

332 from weblate.trans.models import Change, Unit 

333 

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 ) 

352 

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