Coverage for app/venv/lib/python3.14/site-packages/weblate/checks/source.py: 75%

78 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 re 

8from collections import defaultdict 

9from datetime import timedelta 

10from typing import TYPE_CHECKING 

11 

12from django.db.models import Count, F, Q 

13from django.utils import timezone 

14from django.utils.html import format_html, format_html_join 

15from django.utils.translation import gettext, gettext_lazy 

16 

17from weblate.checks.base import BatchCheckMixin, SourceCheck 

18from weblate.utils.html import format_html_join_comma 

19from weblate.utils.state import STATE_EMPTY, STATE_FUZZY 

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 Component, Unit 

25 

26# Matches (s) not followed by alphanumeric chars or at the end 

27PLURAL_MATCH = re.compile(r"\w\(s\)(\W|\Z)") 

28 

29 

30class OptionalPluralCheck(SourceCheck): 

31 """Check for not used plural form.""" 

32 

33 check_id = "optional_plural" 

34 name = gettext_lazy("Unpluralised") 

35 description = gettext_lazy( 

36 "The string is used as plural, but not using plural forms." 

37 ) 

38 

39 def check_source_unit(self, sources: list[str], unit: Unit): 

40 if len(sources) > 1: 40 ↛ 41line 40 didn't jump to line 41 because the condition on line 40 was never true

41 return False 

42 return len(PLURAL_MATCH.findall(sources[0])) > 0 

43 

44 

45class EllipsisCheck(SourceCheck): 

46 """Check for using "..." instead of "…".""" 

47 

48 check_id = "ellipsis" 

49 name = gettext_lazy("Ellipsis") 

50 description = gettext_lazy( 

51 "The string uses three dots (...) instead of an ellipsis character (…)." 

52 ) 

53 

54 def check_source_unit(self, sources: list[str], unit: Unit): 

55 return "..." in sources[0] 

56 

57 

58class MultipleFailingCheck(SourceCheck, BatchCheckMixin): 

59 """Check whether there are more failing checks on this translation.""" 

60 

61 check_id = "multiple_failures" 

62 name = gettext_lazy("Multiple failing checks") 

63 description = gettext_lazy( 

64 "The translations in several languages have failing checks." 

65 ) 

66 

67 def get_related_checks(self, unit_ids: Iterable[int]): 

68 from weblate.checks.models import Check 

69 

70 return Check.objects.filter( 

71 Q(unit_id__in=unit_ids) | Q(unit__source_unit_id__in=unit_ids) 

72 ).select_related("unit__translation") 

73 

74 def check_source_unit(self, sources: list[str], unit: Unit): 

75 if unit.translation.component.batch_checks: 

76 return self.handle_batch(unit, unit.translation.component) 

77 return self._check_unit(unit) 

78 

79 def _check_unit(self, unit: Unit) -> bool: 

80 related = ( 

81 self.get_related_checks([unit.pk]) 

82 .values_list("unit__translation", flat=True) 

83 .distinct() 

84 ) 

85 return related.count() >= 2 

86 

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

88 from weblate.trans.models import Unit 

89 

90 unit_id_and_check_count = ( 

91 self.get_related_checks( 

92 Unit.objects.filter(translation__component=component).values_list( 

93 "pk", flat=True 

94 ) 

95 ) 

96 .values_list("unit__source_unit_id") 

97 .annotate(translation_count=Count("unit__translation_id", distinct=True)) 

98 .filter(translation_count__gte=2) 

99 ) 

100 return ( 

101 Unit.objects.prefetch() 

102 .prefetch_bulk() 

103 .filter( 

104 id__in=unit_id_and_check_count.values_list( 

105 "unit__source_unit_id", flat=True 

106 ) 

107 ) 

108 ) 

109 

110 def get_description(self, check_obj): 

111 unit_ids = check_obj.unit.unit_set.exclude(pk=check_obj.unit.id).values_list( 

112 "pk", flat=True 

113 ) 

114 related = self.get_related_checks(unit_ids).select_related( 

115 "unit__translation__language" 

116 ) 

117 if not related: 

118 return super().get_description(check_obj) 

119 

120 checks = defaultdict(list) 

121 

122 for check in related: 

123 checks[check.name].append(check) 

124 

125 return format_html( 

126 "{}<dl>{}</dl>", 

127 gettext("Following checks are failing:"), 

128 format_html_join( 

129 "\n", 

130 "<dt>{}</dt><dd>{}</dd>", 

131 ( 

132 ( 

133 check_list[0].get_name(), 

134 format_html_join_comma( 

135 '<a href="{}">{}</a>', 

136 ( 

137 ( 

138 check.unit.get_absolute_url(), 

139 str(check.unit.translation.language), 

140 ) 

141 for check in check_list 

142 ), 

143 ), 

144 ) 

145 for check_list in checks.values() 

146 ), 

147 ), 

148 ) 

149 

150 

151class LongUntranslatedCheck(SourceCheck, BatchCheckMixin): 

152 check_id = "long_untranslated" 

153 name = gettext_lazy("Long untranslated") 

154 description = gettext_lazy("The string has not been translated for a long time.") 

155 

156 def check_source_unit(self, sources: list[str], unit: Unit) -> bool: 

157 component = unit.translation.component 

158 if component.batch_checks: 

159 return self.handle_batch(unit, component) 

160 return self._check_unit(unit) 

161 

162 def _check_unit(self, unit: Unit) -> bool: 

163 if unit.timestamp > timezone.now() - timedelta(days=90): 163 ↛ 166line 163 didn't jump to line 166 because the condition on line 163 was always true

164 return False 

165 

166 states = list(unit.unit_set.values_list("state", flat=True)) 

167 total = len(states) 

168 not_translated = states.count(STATE_EMPTY) + states.count(STATE_FUZZY) 

169 translated_percent = 100 * (total - not_translated) / total 

170 return ( 

171 total 

172 and 2 * translated_percent 

173 < unit.translation.component.stats.translated_percent 

174 ) 

175 

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

177 from weblate.trans.models import Unit 

178 

179 result = ( 

180 Unit.objects.filter( 

181 translation__component=component, 

182 source_unit__timestamp__lt=timezone.now() - timedelta(days=90), 

183 ) 

184 .values_list("source_unit_id") 

185 .annotate( 

186 total=Count("state"), 

187 not_translated=Count("state", filter=Q(state__lte=STATE_FUZZY)), 

188 ) 

189 .filter(total__gt=0) 

190 .annotate( 

191 translated_percent=100 * (F("total") - F("not_translated")) / F("total") 

192 ) 

193 ).filter(translated_percent__lt=component.stats.translated_percent / 2) 

194 return ( 

195 Unit.objects.prefetch() 

196 .prefetch_bulk() 

197 .filter(id__in=result.values_list("source_unit_id", flat=True)) 

198 )