Coverage for app/venv/lib/python3.14/site-packages/weblate/checks/base.py: 58%
224 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
7import re
8from typing import TYPE_CHECKING, Any, Literal, TypedDict
10import sentry_sdk
11from django.http import Http404
12from django.utils.html import format_html, format_html_join
13from django.utils.safestring import mark_safe
14from django.utils.translation import gettext
15from lxml import etree
16from siphashc import siphash
18from weblate.utils.classloader import ClassLoaderProtocol
19from weblate.utils.docs import get_doc_url
20from weblate.utils.html import format_html_join_comma
21from weblate.utils.xml import parse_xml
23if TYPE_CHECKING: 23 ↛ 24line 23 didn't jump to line 24 because the condition on line 23 was never true
24 from collections.abc import Callable, Generator, Iterable
26 from django_stubs_ext import StrOrPromise
28 from weblate.auth.models import AuthenticatedHttpRequest, User
29 from weblate.trans.models import Component, Unit
31 from .flags import Flags
32 from .models import Check
34FixupType = (
35 tuple[Literal["regex"], str, str, str] | tuple[Literal["plurals"], list[str]]
36)
39class MissingExtraDict(TypedDict, total=False):
40 missing: list[str]
41 extra: list[str]
42 errors: list[str]
45class BaseCheck(ClassLoaderProtocol):
46 """Basic class for checks."""
48 check_id = ""
49 name: StrOrPromise = ""
50 description: StrOrPromise = ""
51 target = False
52 source = False
53 glossary = False
54 ignore_untranslated = True
55 ignore_readonly = True
56 default_disabled = False
57 propagates: Literal["source", "target"] | None = None
58 param_type: Callable[[tuple[str, ...]], Any] | None = None
59 always_display = False
60 batch_project_wide = False
61 skip_suggestions = False
62 extra_enable_strings: tuple[str, ...] = ()
64 def get_identifier(self) -> str:
65 return self.check_id
67 def __init__(self) -> None:
68 id_dash = self.check_id.replace("_", "-")
69 self.url_id = f"check:{self.check_id}"
70 self.doc_id = f"check-{id_dash}"
71 self.enable_string = id_dash
72 self.ignore_string = f"ignore-{id_dash}"
74 def is_ignored(self, all_flags: Flags) -> bool:
75 return self.ignore_string in all_flags or "ignore-all-checks" in all_flags
77 def should_skip(self, unit: Unit) -> bool:
78 """Check whether we should skip processing this unit."""
79 all_flags = unit.all_flags
80 # Is this check ignored
81 if self.is_ignored(all_flags): 81 ↛ 82line 81 didn't jump to line 82 because the condition on line 81 was never true
82 return True
84 # Is this disabled by default
85 if self.default_disabled:
86 return not all_flags.has_any(
87 {self.enable_string, *self.extra_enable_strings}
88 )
90 # Enabled by default
91 return False
93 def ignore_state(self, unit: Unit) -> bool:
94 if unit.readonly and not self.ignore_readonly:
95 return False
96 return self.ignore_untranslated and (not unit.state or unit.readonly)
98 def should_display(self, unit: Unit) -> bool:
99 """Display the check always, not only when failing."""
100 if self.ignore_state(unit):
101 return False
102 if self.should_skip(unit):
103 return False
104 # Display if enabled and the check is not triggered
105 return self.always_display and self.check_id not in unit.all_checks_names
107 def check_target(self, sources: list[str], targets: list[str], unit: Unit) -> bool:
108 """Check target strings."""
109 # No checking of untranslated units (but we do check needs editing ones)
110 if self.ignore_state(unit):
111 return False
112 if self.should_skip(unit): 112 ↛ 113line 112 didn't jump to line 113 because the condition on line 112 was never true
113 return False
114 if self.check_id in unit.check_cache:
115 return unit.check_cache[self.check_id]
116 unit.check_cache[self.check_id] = result = self.check_target_unit(
117 sources, targets, unit
118 )
119 return result
121 def check_target_generator(
122 self, sources: list[str], targets: list[str], unit: Unit
123 ) -> Generator[bool | MissingExtraDict]:
124 """Check single unit, handling plurals."""
125 from weblate.lang.models import PluralMapper
127 source_plural = unit.translation.component.source_language.plural
128 target_plural = unit.translation.plural
129 if len(sources) != source_plural.number or len(targets) != target_plural.number: 129 ↛ 133line 129 didn't jump to line 133 because the condition on line 129 was always true
130 for target in targets:
131 yield self.check_single(sources[-1], target, unit)
132 else:
133 plural_mapper = PluralMapper(source_plural, target_plural)
134 for source, target in plural_mapper.zip(sources, targets, unit):
135 yield self.check_single(source, target, unit)
137 def check_target_unit(
138 self, sources: list[str], targets: list[str], unit: Unit
139 ) -> bool:
140 """Check single unit, handling plurals."""
141 return any(self.check_target_generator(sources, targets, unit))
143 def check_single(
144 self, source: str, target: str, unit: Unit
145 ) -> bool | MissingExtraDict:
146 """Check for single phrase, not dealing with plurals."""
147 raise NotImplementedError
149 def check_source(self, sources: list[str], unit: Unit) -> bool:
150 """Check source strings."""
151 if self.should_skip(unit):
152 return False
153 return self.check_source_unit(sources, unit)
155 def check_source_unit(self, sources: list[str], unit: Unit) -> bool:
156 """Check source string."""
157 raise NotImplementedError
159 def check_chars(self, source: str, target: str, pos: int, chars: set[str]) -> bool:
160 """Check whether characters are present."""
161 try:
162 src = source[pos]
163 tgt = target[pos]
164 except IndexError:
165 return False
167 return (src in chars) != (tgt in chars)
169 def get_doc_url(self, user: User | None = None) -> str:
170 """Return link to documentation."""
171 return get_doc_url("user/checks", self.doc_id, user=user)
173 def check_highlight(self, source: str, unit: Unit):
174 """
175 Return parts of the text that match to highlight them.
177 Result is list that contains lists of two elements with start position of the
178 match and the value of the match
179 """
180 return []
182 def get_description(self, check_obj: Check) -> StrOrPromise:
183 return self.description
185 def get_fixup(self, unit: Unit) -> Iterable[FixupType] | None:
186 return None
188 def render(self, request: AuthenticatedHttpRequest, unit: Unit) -> StrOrPromise:
189 msg = "Not supported"
190 raise Http404(msg)
192 def get_cache_key(self, unit: Unit, pos: int) -> str:
193 return "check:{}:{}:{}:{}".format(
194 self.check_id,
195 unit.pk,
196 siphash("Weblate Checks", unit.all_flags.format()),
197 pos,
198 )
200 def get_replacement_function(self, unit: Unit):
201 def strip_xml(content: str) -> str:
202 try:
203 tree = parse_xml(f"<x>{content}</x>")
204 except etree.XMLSyntaxError:
205 return content
206 return etree.tostring(tree, encoding="unicode", method="text")
208 def noop(content: str) -> str:
209 return content
211 flags = unit.all_flags
213 # chain XML striping if needed
214 replacement = strip_xml if "xml-text" in flags else noop
216 if not flags.has_value("replacements"):
217 return replacement
219 # Parse the flag
220 replacements = flags.get_value("replacements")
221 # Create dict from that
222 replacements = dict(
223 replacements[pos : pos + 2] for pos in range(0, len(replacements), 2)
224 )
226 # Build regexp matcher
227 pattern = re.compile("|".join(re.escape(key) for key in replacements))
229 return lambda text: pattern.sub(
230 lambda m: replacements[m.group(0)], replacement(text)
231 )
234class BatchCheckMixin(BaseCheck):
235 def handle_batch(self, unit: Unit, component: Component) -> bool:
236 component.batched_checks.add(self.check_id)
237 return self.check_id in unit.all_checks_names
239 def check_component(self, component: Component) -> Iterable[Unit]:
240 raise NotImplementedError
242 def perform_batch(self, component: Component) -> None:
243 with sentry_sdk.start_span(op="check.perform_batch", name=self.check_id):
244 self._perform_batch(component)
246 def _perform_batch(self, component: Component) -> None:
247 from weblate.checks.models import Check
248 from weblate.trans.models import Component
250 handled = set()
251 create = []
252 components = {}
253 for unit in self.check_component(component): 253 ↛ 255line 253 didn't jump to line 255 because the loop on line 253 never started
254 # Handle ignore flags
255 if self.should_skip(unit):
256 continue
257 handled.add(unit.pk)
259 # Check is already there
260 if self.check_id in unit.all_checks_names:
261 continue
263 create.append(Check(unit=unit, dismissed=False, name=self.check_id))
264 components[unit.translation.component.id] = unit.translation.component
266 Check.objects.bulk_create(create, batch_size=500, ignore_conflicts=True)
268 # Delete stale checks
269 stale_checks = Check.objects.exclude(unit_id__in=handled)
270 if self.batch_project_wide and component.allow_translation_propagation:
271 stale_checks = stale_checks.filter(
272 unit__translation__component__project=component.project,
273 unit__translation__component__allow_translation_propagation=True,
274 name=self.check_id,
275 )
276 for current in Component.objects.filter( 276 ↛ 281line 276 didn't jump to line 281 because the loop on line 276 never started
277 pk__in=stale_checks.values_list(
278 "unit__translation__component", flat=True
279 )
280 ):
281 components[current.pk] = current
282 stale_checks.delete()
283 else:
284 stale_checks = stale_checks.filter(
285 unit__translation__component=component,
286 name=self.check_id,
287 )
288 if stale_checks.delete()[0]: 288 ↛ 289line 288 didn't jump to line 289 because the condition on line 288 was never true
289 components[component.id] = component
291 # Invalidate stats in case there were changes
292 for current in components.values(): 292 ↛ 293line 292 didn't jump to line 293 because the loop on line 292 never started
293 current.invalidate_cache()
296class TargetCheck(BaseCheck):
297 """Basic class for target checks."""
299 target = True
301 def check_source_unit(self, sources: list[str], unit: Unit) -> bool:
302 """We don't check source strings here."""
303 return False
305 def check_single(self, source: str, target: str, unit: Unit) -> bool:
306 """Check for single phrase, not dealing with plurals."""
307 raise NotImplementedError
309 def format_value(self, value: str) -> StrOrPromise:
310 from weblate.trans.templatetags.translations import Formatter
312 fmt = Formatter(0, value, None, None, None, None, None)
313 fmt.parse()
314 return format_html(
315 """<span class="hlcheck" data-value="{}">{}</span>""", value, fmt.format()
316 )
318 def get_values_text(self, message: str, values: Iterable[str]) -> StrOrPromise:
319 return format_html(
320 message,
321 format_html_join_comma(
322 "{}",
323 ((self.format_value(value),) for value in sorted(values)),
324 ),
325 )
327 def get_missing_text(self, values: Iterable[str]) -> StrOrPromise:
328 return self.get_values_text(
329 gettext("The following format strings are missing: {}"), values
330 )
332 def get_extra_text(self, values: Iterable[str]) -> StrOrPromise:
333 return self.get_values_text(
334 gettext("The following format strings are extra: {}"), values
335 )
337 def get_errors_text(self, values: Iterable[str]) -> StrOrPromise:
338 return format_html_join(
339 mark_safe("<br />"),
340 "{}",
341 (
342 (value,)
343 for value in (gettext("The following errors were found:"), *values)
344 ),
345 )
347 def format_string(self, string: str) -> str:
348 """Format parsed format string into human readable value."""
349 return string
351 def format_result(self, result: MissingExtraDict) -> Iterable[StrOrPromise]:
352 if missing := result.get("missing"):
353 yield self.get_missing_text(self.format_string(x) for x in set(missing))
354 if extra := result.get("extra"):
355 yield self.get_extra_text(self.format_string(x) for x in set(extra))
356 if errors := result.get("errors"):
357 yield self.get_errors_text(set(errors))
360class SourceCheck(BaseCheck):
361 """Basic class for source checks."""
363 source = True
365 def check_single(self, source: str, target: str, unit: Unit) -> bool:
366 """Target strings are checked in check_target_unit."""
367 return False
369 def check_source_unit(self, sources: list[str], unit: Unit) -> bool:
370 """Check source string."""
371 raise NotImplementedError
374class TargetCheckParametrized(TargetCheck):
375 """Basic class for target checks with flag value."""
377 default_disabled = True
379 def get_value(self, unit: Unit) -> Any: # noqa: ANN401
380 return unit.all_flags.get_value(self.enable_string)
382 def has_value(self, unit: Unit) -> bool:
383 return unit.all_flags.has_value(self.enable_string)
385 def check_target_unit(
386 self, sources: list[str], targets: list[str], unit: Unit
387 ) -> bool:
388 """Check flag value."""
389 if unit.all_flags.has_value(self.enable_string):
390 return self.check_target_params(
391 sources, targets, unit, self.get_value(unit)
392 )
393 return False
395 def check_target_params(
396 self,
397 sources: list[str],
398 targets: list[str],
399 unit: Unit,
400 value: Any, # noqa: ANN401
401 ) -> bool:
402 raise NotImplementedError
404 def check_single(self, source: str, target: str, unit: Unit) -> bool:
405 """We don't check single phrase here."""
406 return False
409class CountingCheck(TargetCheck):
410 """Check whether there is same count of given string."""
412 string = ""
414 def check_single(self, source: str, target: str, unit: Unit) -> bool:
415 if not target or not source:
416 return False
417 return source.count(self.string) != target.count(self.string)