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

102 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 typing import TYPE_CHECKING, Any, Literal 

8 

9import regex 

10from django.utils.functional import SimpleLazyObject 

11from django.utils.html import escape, format_html, format_html_join 

12from django.utils.safestring import mark_safe 

13from django.utils.translation import gettext_lazy 

14 

15from weblate.checks.base import TargetCheckParametrized 

16from weblate.checks.parser import multi_value_flag, single_value_flag 

17 

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

19 from weblate.trans.models import Unit 

20 

21 

22def parse_regex(val): 

23 if isinstance(val, str): 

24 return regex.compile(val) 

25 return val 

26 

27 

28class PlaceholderCheck(TargetCheckParametrized): 

29 check_id = "placeholders" 

30 default_disabled = True 

31 name = gettext_lazy("Placeholders") 

32 description = gettext_lazy("Translation is missing some placeholders.") 

33 

34 @property 

35 def param_type(self): 

36 return multi_value_flag(lambda x: x) 

37 

38 def get_value(self, unit: Unit): 

39 return regex.compile( 

40 "|".join( 

41 regex.escape(param) if isinstance(param, str) else param.pattern 

42 for param in super().get_value(unit) 

43 ), 

44 regex.IGNORECASE if "case-insensitive" in unit.all_flags else 0, 

45 ) 

46 

47 @staticmethod 

48 def get_matches(value, text: str): 

49 for match in value.finditer(text, concurrent=True): 

50 yield match.group() 

51 

52 def diff_case_sensitive(self, expected, found): 

53 return expected - found, found - expected 

54 

55 def diff_case_insensitive(self, expected, found): 

56 expected_fold = {v.casefold(): v for v in expected} 

57 found_fold = {v.casefold(): v for v in found} 

58 

59 expected_set = set(expected_fold) 

60 found_set = set(found_fold) 

61 

62 return ( 

63 {expected_fold[v] for v in expected_set - found_set}, 

64 {found_fold[v] for v in found_set - expected_set}, 

65 ) 

66 

67 def check_target_unit( # type: ignore[override] 

68 self, sources: list[str], targets: list[str], unit: Unit 

69 ) -> Literal[False] | dict[str, Any]: 

70 # TODO: this is type annotation hack, instead the check should have a proper return type 

71 return super().check_target_unit(sources, targets, unit) # type: ignore[return-value] 

72 

73 def check_target_params( # type: ignore[override] 

74 self, sources: list[str], targets: list[str], unit: Unit, value 

75 ) -> Literal[False] | dict[str, Any]: 

76 expected = set(self.get_matches(value, sources[0])) 

77 if not expected and len(sources) > 1: 

78 expected = set(self.get_matches(value, sources[-1])) 

79 plural_examples = SimpleLazyObject(lambda: unit.translation.plural.examples) 

80 

81 if "case-insensitive" in unit.all_flags: 

82 diff_func = self.diff_case_insensitive 

83 else: 

84 diff_func = self.diff_case_sensitive 

85 

86 missing = set() 

87 extra = set() 

88 

89 for pluralno, target in enumerate(targets): 

90 found = set(self.get_matches(value, target)) 

91 diff = diff_func(expected, found) 

92 plural_example = plural_examples[pluralno] 

93 # Allow to skip format string in case there is single plural or in special 

94 # case of 0, 1 plural. It is technically wrong, but in many cases there 

95 # won't be 0 so don't trigger too many false positives 

96 if len(targets) == 1 or ( 

97 len(plural_example) > 1 and plural_example != ["0", "1"] 

98 ): 

99 missing.update(diff[0]) 

100 extra.update(diff[1]) 

101 

102 if missing or extra: 

103 return {"missing": missing, "extra": extra} 

104 return False 

105 

106 def check_highlight(self, source: str, unit: Unit): 

107 if self.should_skip(unit): 107 ↛ 110line 107 didn't jump to line 110 because the condition on line 107 was always true

108 return 

109 

110 regexp = self.get_value(unit) 

111 

112 for match in regexp.finditer(source): 

113 yield (match.start(), match.end(), match.group()) 

114 

115 def get_description(self, check_obj): 

116 unit = check_obj.unit 

117 result = self.check_target_unit( 

118 unit.get_source_plurals(), unit.get_target_plurals(), unit 

119 ) 

120 if not result: 

121 return super().get_description(check_obj) 

122 

123 errors = [] 

124 if result["missing"]: 

125 errors.append(self.get_missing_text(result["missing"])) 

126 if result["extra"]: 

127 errors.append(self.get_extra_text(result["extra"])) 

128 

129 return format_html_join( 

130 mark_safe("<br />"), 

131 "{}", 

132 ((error,) for error in errors), 

133 ) 

134 

135 

136class RegexCheck(TargetCheckParametrized): 

137 check_id = "regex" 

138 default_disabled = True 

139 name = gettext_lazy("Regular expression") 

140 description = gettext_lazy("Translation does not match regular expression.") 

141 

142 @property 

143 def param_type(self): 

144 return single_value_flag(parse_regex) 

145 

146 def check_target_params( 

147 self, sources: list[str], targets: list[str], unit: Unit, value 

148 ): 

149 return any(not value.findall(target) for target in targets) 

150 

151 def should_skip(self, unit: Unit) -> bool: 

152 if super().should_skip(unit): 152 ↛ 154line 152 didn't jump to line 154 because the condition on line 152 was always true

153 return True 

154 return not self.get_value(unit).pattern 

155 

156 def check_highlight(self, source: str, unit: Unit): 

157 if self.should_skip(unit): 157 ↛ 160line 157 didn't jump to line 160 because the condition on line 157 was always true

158 return 

159 

160 regex = self.get_value(unit) 

161 

162 for match in regex.finditer(source): 

163 yield (match.start(), match.end(), match.group()) 

164 

165 def get_description(self, check_obj): 

166 unit = check_obj.unit 

167 if not self.has_value(unit): 

168 return super().get_description(check_obj) 

169 regex = self.get_value(unit) 

170 return format_html( 

171 escape(gettext_lazy("Does not match regular expression {}.")), 

172 format_html("<code>{}</code>", regex.pattern), 

173 )