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
« 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
5from __future__ import annotations
7from typing import TYPE_CHECKING, Any, Literal
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
15from weblate.checks.base import TargetCheckParametrized
16from weblate.checks.parser import multi_value_flag, single_value_flag
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
22def parse_regex(val):
23 if isinstance(val, str):
24 return regex.compile(val)
25 return val
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.")
34 @property
35 def param_type(self):
36 return multi_value_flag(lambda x: x)
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 )
47 @staticmethod
48 def get_matches(value, text: str):
49 for match in value.finditer(text, concurrent=True):
50 yield match.group()
52 def diff_case_sensitive(self, expected, found):
53 return expected - found, found - expected
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}
59 expected_set = set(expected_fold)
60 found_set = set(found_fold)
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 )
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]
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)
81 if "case-insensitive" in unit.all_flags:
82 diff_func = self.diff_case_insensitive
83 else:
84 diff_func = self.diff_case_sensitive
86 missing = set()
87 extra = set()
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])
102 if missing or extra:
103 return {"missing": missing, "extra": extra}
104 return False
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
110 regexp = self.get_value(unit)
112 for match in regexp.finditer(source):
113 yield (match.start(), match.end(), match.group())
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)
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"]))
129 return format_html_join(
130 mark_safe("<br />"),
131 "{}",
132 ((error,) for error in errors),
133 )
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.")
142 @property
143 def param_type(self):
144 return single_value_flag(parse_regex)
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)
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
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
160 regex = self.get_value(unit)
162 for match in regex.finditer(source):
163 yield (match.start(), match.end(), match.group())
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 )