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
« 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
6from __future__ import annotations
8import re
9from functools import lru_cache
10from typing import TYPE_CHECKING, Any, ClassVar, cast
12from django.core.exceptions import ValidationError
13from django.utils.translation import gettext, gettext_lazy
14from lxml import etree
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
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
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)
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}
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")
70DISCARD_FLAG = "discard"
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)
100IGNORE_CHECK_FLAGS = {check.ignore_string for check in CHECKS.values()} | set(
101 AUTOFIXES.get_ignore_strings()
102)
104FLAG_ALIASES = {"markdown-text": "md-text"}
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] = []
113 with FLAGS_PARSER_LOCK:
114 tokens = list(FLAGS_PARSER.parse_string(flags, parse_all=True))
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)
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
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))
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)
203class Flags:
204 _apply_discard: ClassVar[bool] = True
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)
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
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
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)
260 def has_value(self, key: str) -> bool:
261 return isinstance(self._items.get(key), tuple)
263 def get_value_raw(self, key: str) -> tuple[Any, ...]:
264 return cast("tuple", self._items[key])[1:]
266 def get_value(self, key: str) -> Any: # noqa: ANN401
267 return TYPED_FLAGS_ARGS[key](self.get_value_raw(key))
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
279 def items(self) -> set[str | tuple[Any, ...]]:
280 return set(self._items.values())
282 def __iter__(self) -> Iterator[str | tuple[Any, ...]]:
283 return self._items.__iter__()
285 def __contains__(self, key: str) -> bool:
286 return key in self._items
288 def __bool__(self) -> bool:
289 return bool(self._items)
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()
297 def __hash__(self) -> int:
298 return hash(self.items())
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
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)
320 def _format_values(self) -> Generator[str]:
321 return (self.format_flag(item) for item in self._items.values())
323 def format(self) -> str:
324 return ", ".join(sorted(self._format_values()))
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
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 )
354 def set_value(self, name: str, value: str) -> None:
355 self._items[name] = (name, value)
357 def has_any(self, flags: set[str]) -> bool:
358 return bool(flags & set(self._items.keys()))
361class FlagsValidator(Flags):
362 _apply_discard: ClassVar[bool] = False