Coverage for app/venv/lib/python3.14/site-packages/weblate/checks/fluent/parts.py: 26%

72 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-07 07:15 +0000

1# Copyright © Henry Wilkes <henry@torproject.org> 

2# 

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

4 

5from __future__ import annotations 

6 

7import re 

8from typing import TYPE_CHECKING 

9 

10from django.utils.translation import gettext, gettext_lazy 

11 

12from weblate.checks.base import TargetCheck 

13from weblate.checks.fluent.utils import ( 

14 FluentPatterns, 

15 FluentUnitConverter, 

16 format_html_code, 

17 format_html_error_list, 

18 translation_from_check, 

19) 

20 

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

22 from django.utils.safestring import SafeString 

23 from django_stubs_ext import StrOrPromise 

24 from translate.storage.fluent import FluentPart 

25 

26 from weblate.checks.fluent.utils import CheckModel, HighlightsType, TransUnitModel 

27 

28 

29class _PartsDifference: 

30 """Represents the missing or extra parts in the target.""" 

31 

32 def __init__( 

33 self, 

34 source_parts: list[FluentPart], 

35 target_parts: list[FluentPart], 

36 ) -> None: 

37 # We don't expect any duplicate part names since that should raise a 

38 # syntax error in the translation toolkit. 

39 self._missing = [ 

40 part.name 

41 for part in source_parts 

42 if not self._has_matching_part(target_parts, part) 

43 ] 

44 self._extra = [ 

45 part.name 

46 for part in target_parts 

47 if not self._has_matching_part(source_parts, part) 

48 ] 

49 

50 @staticmethod 

51 def _has_matching_part( 

52 part_list: list[FluentPart], 

53 find_part: FluentPart, 

54 ) -> bool: 

55 return any(part.name == find_part.name for part in part_list) 

56 

57 def __bool__(self) -> bool: 

58 return bool(self._missing or self._extra) 

59 

60 def description(self) -> SafeString: 

61 errors = [] 

62 if "" in self._missing: 

63 errors.append(gettext("Fluent value is empty.")) 

64 if "" in self._extra: 

65 errors.append(gettext("Fluent value should be empty.")) 

66 for part_name in self._missing: 

67 if not part_name: 

68 continue 

69 errors.append( 

70 format_html_code( 

71 gettext("Missing Fluent attribute: {hint}"), 

72 hint=f".{part_name}\xa0=\xa0…", 

73 ) 

74 ) 

75 for part_name in self._extra: 

76 if not part_name: 

77 continue 

78 errors.append( 

79 format_html_code( 

80 gettext("Unexpected Fluent attribute: {hint}"), 

81 hint=f".{part_name}\xa0=\xa0…", 

82 ) 

83 ) 

84 

85 return format_html_error_list(errors) 

86 

87 

88class FluentPartsCheck(TargetCheck): 

89 """ 

90 Check that the target has the same Fluent parts as the source. 

91 

92 Each Fluent Message can have an optional value (the main text content), and 

93 optional attributes, each of which is a "part" of the Message. In Weblate, 

94 all these parts appear within the same block, using Fluent-like syntax to 

95 specify the attributes. For example: 

96 

97 | This is the Message value 

98 | .title = This is the title attribute 

99 | .alt = This is the alt attribute 

100 

101 This check ensures that the translated Message also has a value if the 

102 source Message has one, or no value if the source has none. This also checks 

103 that the same attributes used in the source Message also appear in the 

104 translation, with no additions. 

105 

106 NOTE: This check is not applied to Fluent Terms since Terms always have a 

107 value, and Term attributes tend to be locale-specific (used for grammar 

108 rules, etc.), and are not expected to appear in all translations. 

109 """ 

110 

111 check_id = "fluent-parts" 

112 name = gettext_lazy("Fluent parts") 

113 description = gettext_lazy("Fluent parts should match.") 

114 default_disabled = True 

115 

116 @classmethod 

117 def _compare_parts( 

118 cls, unit: TransUnitModel, source: str, target: str 

119 ) -> _PartsDifference | None: 

120 """Compare the list of parts found in the source and target.""" 

121 source_unit = FluentUnitConverter(unit, source) 

122 if source_unit.fluent_type() == "Term": 

123 # Don't want to check Terms since their attributes are 

124 # locale-specific and a Term missing a value is a fluent syntax 

125 # error. 

126 return None 

127 

128 source_parts = source_unit.to_fluent_parts() 

129 

130 if source_parts is None: 

131 # Some syntax error. 

132 return None 

133 

134 target_parts = FluentUnitConverter(unit, target).to_fluent_parts() 

135 

136 if target_parts is None: 

137 # Some syntax error, so don't compare. 

138 return None 

139 

140 return _PartsDifference(source_parts, target_parts) 

141 

142 def check_single( 

143 self, 

144 source: str, 

145 target: str, 

146 unit: TransUnitModel, 

147 ) -> bool: 

148 return bool(self._compare_parts(unit, source, target)) 

149 

150 def check_highlight( 

151 self, 

152 source: str, 

153 unit: TransUnitModel, 

154 ) -> HighlightsType: 

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

156 return [] 

157 

158 # We want to highlight the attribute syntax in Messages. 

159 

160 fluent_unit = FluentUnitConverter(unit, source) 

161 if fluent_unit.fluent_type() == "Term": 

162 return [] 

163 

164 highlight_patterns = [] 

165 for part in fluent_unit.to_fluent_parts() or []: 

166 if not part.name: 

167 # Don't highlight the value since there is no visible syntax for 

168 # it. 

169 continue 

170 # We want to match ".attr-name =" at the line start for each 

171 # attribute. 

172 # The attribute name shouldn't need escaping, but we do so here for 

173 # safety. 

174 highlight_patterns.append(r"^ *\." + re.escape(part.name) + r" *=") 

175 return FluentPatterns.highlight_source(source, highlight_patterns) 

176 

177 def get_description(self, check_model: CheckModel) -> StrOrPromise: 

178 (unit, source, target) = translation_from_check(check_model) 

179 difference = self._compare_parts(unit, source, target) 

180 if not difference: 

181 return super().get_description(check_model) 

182 

183 return difference.description()