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

233 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# ruff: noqa: S105 

5 

6from __future__ import annotations 

7 

8import re 

9from functools import lru_cache 

10from typing import TYPE_CHECKING, Any, ClassVar, cast 

11 

12from django.core.exceptions import ValidationError 

13from django.utils.translation import gettext, gettext_lazy 

14from lxml import etree 

15 

16from weblate.checks.models import CHECKS 

17from weblate.checks.parser import ( 

18 FLAGS_PARSER, 

19 FLAGS_PARSER_LOCK, 

20 SYNTAXCHARS, 

21 length_validation, 

22 multi_value_flag, 

23 single_value_flag, 

24) 

25from weblate.fonts.utils import get_font_weight 

26from weblate.trans.autofixes import AUTOFIXES 

27from weblate.trans.defines import VARIANT_KEY_LENGTH 

28 

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

30 from collections.abc import Generator, Iterator 

31 

32 

33def discard_flag_validation(name: str) -> None: 

34 """Raise ValueError when parameter is not a valid flag name.""" 

35 if ( 

36 name not in TYPED_FLAGS 

37 and name not in PLAIN_FLAGS 

38 and name not in IGNORE_CHECK_FLAGS 

39 ): 

40 msg = f"Does not match flag name: {name}" 

41 raise ValueError(msg) 

42 

43 

44PLAIN_FLAGS = { 

45 v.enable_string: v.name 

46 for k, v in CHECKS.items() 

47 if v.default_disabled and not v.param_type 

48} 

49TYPED_FLAGS = {v.enable_string: v.name for k, v in CHECKS.items() if v.param_type} 

50TYPED_FLAGS_ARGS = { 

51 v.enable_string: v.param_type for k, v in CHECKS.items() if v.param_type 

52} 

53 

54PLAIN_FLAGS["rst-text"] = gettext_lazy("RST text") 

55PLAIN_FLAGS["md-text"] = gettext_lazy("Markdown text") 

56PLAIN_FLAGS["xml-text"] = gettext_lazy("XML text") 

57PLAIN_FLAGS["dos-eol"] = gettext_lazy("DOS line endings") 

58PLAIN_FLAGS["url"] = gettext_lazy("URL") 

59PLAIN_FLAGS["auto-java-messageformat"] = gettext_lazy( 

60 "Automatically detect Java MessageFormat" 

61) 

62PLAIN_FLAGS["read-only"] = gettext_lazy("Read-only") 

63PLAIN_FLAGS["strict-same"] = gettext_lazy("Strict unchanged check") 

64PLAIN_FLAGS["strict-format"] = gettext_lazy("Strict format string checks") 

65PLAIN_FLAGS["forbidden"] = gettext_lazy("Forbidden translation") 

66PLAIN_FLAGS["terminology"] = gettext_lazy("Terminology") 

67PLAIN_FLAGS["ignore-all-checks"] = gettext_lazy("Ignore all checks") 

68PLAIN_FLAGS["case-insensitive"] = gettext_lazy("Use case insensitive placeholders") 

69 

70DISCARD_FLAG = "discard" 

71 

72TYPED_FLAGS["font-family"] = gettext_lazy("Font family") 

73TYPED_FLAGS_ARGS["font-family"] = single_value_flag(str) 

74TYPED_FLAGS["font-size"] = gettext_lazy("Font size") 

75TYPED_FLAGS_ARGS["font-size"] = single_value_flag(int) 

76TYPED_FLAGS["font-weight"] = gettext_lazy("Font weight") 

77TYPED_FLAGS_ARGS["font-weight"] = single_value_flag(get_font_weight) 

78TYPED_FLAGS["font-spacing"] = gettext_lazy("Font spacing") 

79TYPED_FLAGS_ARGS["font-spacing"] = single_value_flag(int) 

80TYPED_FLAGS["icu-flags"] = gettext_lazy("ICU MessageFormat flags") 

81TYPED_FLAGS_ARGS["icu-flags"] = multi_value_flag(str) 

82TYPED_FLAGS["icu-tag-prefix"] = gettext_lazy("ICU MessageFormat tag prefix") 

83TYPED_FLAGS_ARGS["icu-tag-prefix"] = single_value_flag(str) 

84TYPED_FLAGS["priority"] = gettext_lazy("Priority") 

85TYPED_FLAGS_ARGS["priority"] = single_value_flag(int) 

86TYPED_FLAGS["max-length"] = gettext_lazy("Maximum length of translation") 

87TYPED_FLAGS_ARGS["max-length"] = single_value_flag(int) 

88TYPED_FLAGS["replacements"] = gettext_lazy("Replacements while rendering") 

89TYPED_FLAGS_ARGS["replacements"] = multi_value_flag(str, modulo=2) 

90TYPED_FLAGS["variant"] = gettext_lazy("String variant") 

91TYPED_FLAGS_ARGS["variant"] = single_value_flag( 

92 str, length_validation(VARIANT_KEY_LENGTH) 

93) 

94TYPED_FLAGS["fluent-type"] = gettext_lazy("Fluent type") 

95TYPED_FLAGS_ARGS["fluent-type"] = single_value_flag(str) 

96TYPED_FLAGS[DISCARD_FLAG] = gettext_lazy("Discard flag") 

97TYPED_FLAGS_ARGS[DISCARD_FLAG] = single_value_flag(str, discard_flag_validation) 

98 

99 

100IGNORE_CHECK_FLAGS = {check.ignore_string for check in CHECKS.values()} | set( 

101 AUTOFIXES.get_ignore_strings() 

102) 

103 

104FLAG_ALIASES = {"markdown-text": "md-text"} 

105 

106 

107def _parse_flags_text(flags: str) -> Iterator[str | tuple[Any, ...]]: 

108 """Parse comma separated list of flags.""" 

109 state = 0 

110 name: str 

111 value: list[Any] = [] 

112 

113 with FLAGS_PARSER_LOCK: 

114 tokens = list(FLAGS_PARSER.parse_string(flags, parse_all=True)) 

115 

116 for pos, token in enumerate(tokens): 

117 if state == 0 and token == ",": 117 ↛ 118line 117 didn't jump to line 118 because the condition on line 117 was never true

118 pass 

119 elif state == 0: 

120 # Handle aliases 

121 name = FLAG_ALIASES.get(token, token) 

122 value = [name] 

123 state = 1 

124 elif state == 1 and token == ",": 124 ↛ 128line 124 didn't jump to line 128 because the condition on line 124 was always true

125 # End of flag 

126 state = 0 

127 yield name 

128 elif state in {1, 3} and token == ":": 

129 # Value separator 

130 state = 2 

131 elif state == 2 and token == ",": 

132 # Flag with empty parameter 

133 state = 0 

134 value.append("") 

135 yield tuple(value) 

136 elif state == 2 and token == ":": 

137 # Empty param 

138 value.append("") 

139 elif state == 2: 

140 if ( 

141 token == "r" 

142 and pos + 1 < len(tokens) 

143 and tokens[pos + 1] not in {",", ":"} 

144 ): 

145 # Regex prefix, value follows 

146 state = 4 

147 else: 

148 # Value 

149 value.append(token) 

150 state = 3 

151 elif state == 4: 

152 # Regex value 

153 value.append(re.compile(token)) 

154 state = 3 

155 elif state == 3 and token == ",": 

156 # Last value 

157 yield tuple(value) 

158 state = 0 

159 else: 

160 msg = f"Unexpected token: {token}, state={state}" 

161 raise ValueError(msg) 

162 

163 # With state 0 there was nothing parsed yet 

164 if state > 0: 

165 if state == 2: 165 ↛ 167line 165 didn't jump to line 167 because the condition on line 165 was never true

166 # There was empty value 

167 value.append("") 

168 # Is this flag or flag with value 

169 if len(value) > 1: 169 ↛ 170line 169 didn't jump to line 170 because the condition on line 169 was never true

170 yield tuple(value) 

171 else: 

172 yield name 

173 

174 

175@lru_cache(maxsize=512) 

176def parse_flags_text(flags: str) -> tuple[str | tuple[Any, ...], ...]: 

177 """Parse comma separated list of flags.""" 

178 return tuple(_parse_flags_text(flags)) 

179 

180 

181def parse_flags_xml(flags: etree._Element) -> Iterator[str | tuple[Any, ...]]: 

182 """Parse comma separated list of flags.""" 

183 maxwidth = flags.get("maxwidth") 

184 sizeunit = flags.get("size-unit") 

185 if maxwidth: 

186 if sizeunit in {None, "pixel", "point"}: 

187 yield "max-size", maxwidth 

188 elif sizeunit in {"byte", "char"}: 

189 yield "max-length", maxwidth 

190 font = flags.get("font") 

191 if font: 

192 font_parts = font.split(";") 

193 yield "font-family", font_parts[0].strip().replace(" ", "_") 

194 if len(font_parts) > 1: 

195 yield "font-size", font_parts[1].strip() 

196 if len(font_parts) > 2: 

197 yield "font-weight", font_parts[2].strip() 

198 text = flags.get("weblate-flags") 

199 if text: 

200 yield from parse_flags_text(text) 

201 

202 

203class Flags: 

204 _apply_discard: ClassVar[bool] = True 

205 

206 def __init__( 

207 self, *args: str | etree._Element | Flags | tuple[str | tuple[Any, ...]] | None 

208 ) -> None: 

209 self._items: dict[str, str | tuple[Any, ...]] = {} 

210 for flags in args: 

211 self.merge(flags) 

212 

213 def get_items( 

214 self, flags: str | etree._Element | Flags | tuple[str | tuple[Any, ...]] | None 

215 ) -> tuple[str | tuple[Any, ...], ...]: 

216 if flags is None: 216 ↛ 217line 216 didn't jump to line 217 because the condition on line 216 was never true

217 return () 

218 if isinstance(flags, str): 

219 return parse_flags_text(flags) 

220 if isinstance(flags, etree._Element): # noqa: SLF001 220 ↛ 221line 220 didn't jump to line 221 because the condition on line 220 was never true

221 return tuple(parse_flags_xml(flags)) 

222 if isinstance(flags, Flags): 

223 return tuple(flags.items()) 

224 return flags 

225 

226 def merge( 

227 self, flags: str | etree._Element | Flags | tuple[str | tuple[Any, ...]] | None 

228 ) -> None: 

229 for flag in self.get_items(flags): 

230 if ( 230 ↛ 237line 230 didn't jump to line 237 because the condition on line 230 was never true

231 self._apply_discard 

232 and isinstance(flag, tuple) 

233 and len(flag) == 2 

234 and flag[0] == DISCARD_FLAG 

235 ): 

236 # Discard the current value if present 

237 self._items.pop(flag[1], None) 

238 elif isinstance(flag, tuple): 238 ↛ 239line 238 didn't jump to line 239 because the condition on line 238 was never true

239 self._items[flag[0]] = flag 

240 elif flag and flag not in {"fuzzy", "#"}: 240 ↛ 229line 240 didn't jump to line 229 because the condition on line 240 was always true

241 # Ignore some flags 

242 self._items[flag] = flag 

243 

244 def remove( 

245 self, flags: str | etree._Element | Flags | tuple[str | tuple[Any, ...]] | None 

246 ) -> None: 

247 for flag in self.get_items(flags): 

248 if isinstance(flag, tuple): 248 ↛ 249line 248 didn't jump to line 249 because the condition on line 248 was never true

249 key = flag[0] 

250 current = self._items.get(key) 

251 if current is None: 

252 continue 

253 if isinstance(current, str): 

254 continue 

255 if flag == current: 

256 del self._items[key] 

257 else: 

258 self._items.pop(flag, None) 

259 

260 def has_value(self, key: str) -> bool: 

261 return isinstance(self._items.get(key), tuple) 

262 

263 def get_value_raw(self, key: str) -> tuple[Any, ...]: 

264 return cast("tuple", self._items[key])[1:] 

265 

266 def get_value(self, key: str) -> Any: # noqa: ANN401 

267 return TYPED_FLAGS_ARGS[key](self.get_value_raw(key)) 

268 

269 def get_value_fallback(self, key: str, fallback: Any) -> Any: # noqa: ANN401 

270 try: 

271 value = self.get_value_raw(key) 

272 except KeyError: 

273 return fallback 

274 try: 

275 return TYPED_FLAGS_ARGS[key](value) 

276 except KeyError: 

277 return fallback 

278 

279 def items(self) -> set[str | tuple[Any, ...]]: 

280 return set(self._items.values()) 

281 

282 def __iter__(self) -> Iterator[str | tuple[Any, ...]]: 

283 return self._items.__iter__() 

284 

285 def __contains__(self, key: str) -> bool: 

286 return key in self._items 

287 

288 def __bool__(self) -> bool: 

289 return bool(self._items) 

290 

291 def __eq__(self, other: object) -> bool: 

292 """Check if current flags are equal to other flags.""" 

293 if not isinstance(other, Flags): 293 ↛ 294line 293 didn't jump to line 294 because the condition on line 293 was never true

294 return False 

295 return self.items() == other.items() 

296 

297 def __hash__(self) -> int: 

298 return hash(self.items()) 

299 

300 @staticmethod 

301 def format_value(value: str | re.Pattern[str]) -> str: 

302 # Regexp objects 

303 prefix = "" 

304 if isinstance(value, re.Pattern): 

305 value = value.pattern 

306 prefix = "r" 

307 if prefix or " " in value or any(c in value for c in SYNTAXCHARS): 

308 return '{}"{}"'.format( 

309 prefix, 

310 value.replace('"', r"\"").replace("\n", "\\n").replace("\r", "\\r"), 

311 ) 

312 return value 

313 

314 @classmethod 

315 def format_flag(cls, flag: tuple[str, ...] | list[str] | str) -> str: 

316 if isinstance(flag, (tuple, list)): 

317 return ":".join(cls.format_value(val) for val in flag) 

318 return cls.format_value(flag) 

319 

320 def _format_values(self) -> Generator[str]: 

321 return (self.format_flag(item) for item in self._items.values()) 

322 

323 def format(self) -> str: 

324 return ", ".join(sorted(self._format_values())) 

325 

326 def validate(self) -> None: 

327 for item in self._items.values(): 327 ↛ exitline 327 didn't return from function 'validate' because the loop on line 327 didn't complete

328 if isinstance(item, tuple): 328 ↛ 329line 328 didn't jump to line 329 because the condition on line 328 was never true

329 name, value = item[0], item[1:] 

330 else: 

331 name = item 

332 value = None 

333 

334 is_typed = name in TYPED_FLAGS 

335 is_plain = name in PLAIN_FLAGS or name in IGNORE_CHECK_FLAGS 

336 if not is_typed and not is_plain: 336 ↛ 338line 336 didn't jump to line 338 because the condition on line 336 was always true

337 raise ValidationError(gettext('Invalid translation flag: "%s"') % name) 

338 if value is not None: 

339 if is_plain: 

340 raise ValidationError( 

341 gettext('The translation flag has no parameters: "%s"') % name 

342 ) 

343 try: 

344 self.get_value(name) 

345 except Exception as error: 

346 raise ValidationError( 

347 gettext('Wrong parameters for translation flag: "%s"') % name 

348 ) from error 

349 elif is_typed: 

350 raise ValidationError( 

351 gettext('Missing parameters for translation flag: "%s"') % name 

352 ) 

353 

354 def set_value(self, name: str, value: str) -> None: 

355 self._items[name] = (name, value) 

356 

357 def has_any(self, flags: set[str]) -> bool: 

358 return bool(flags & set(self._items.keys())) 

359 

360 

361class FlagsValidator(Flags): 

362 _apply_discard: ClassVar[bool] = False