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

1# Copyright © Michal Čihař <michal@weblate.org> 

2# 

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

4 

5from __future__ import annotations 

6 

7import re 

8from typing import TYPE_CHECKING, Any, Literal, TypedDict 

9 

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 

17 

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 

22 

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 

25 

26 from django_stubs_ext import StrOrPromise 

27 

28 from weblate.auth.models import AuthenticatedHttpRequest, User 

29 from weblate.trans.models import Component, Unit 

30 

31 from .flags import Flags 

32 from .models import Check 

33 

34FixupType = ( 

35 tuple[Literal["regex"], str, str, str] | tuple[Literal["plurals"], list[str]] 

36) 

37 

38 

39class MissingExtraDict(TypedDict, total=False): 

40 missing: list[str] 

41 extra: list[str] 

42 errors: list[str] 

43 

44 

45class BaseCheck(ClassLoaderProtocol): 

46 """Basic class for checks.""" 

47 

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, ...] = () 

63 

64 def get_identifier(self) -> str: 

65 return self.check_id 

66 

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}" 

73 

74 def is_ignored(self, all_flags: Flags) -> bool: 

75 return self.ignore_string in all_flags or "ignore-all-checks" in all_flags 

76 

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 

83 

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 ) 

89 

90 # Enabled by default 

91 return False 

92 

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) 

97 

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 

106 

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 

120 

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 

126 

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) 

136 

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)) 

142 

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 

148 

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) 

154 

155 def check_source_unit(self, sources: list[str], unit: Unit) -> bool: 

156 """Check source string.""" 

157 raise NotImplementedError 

158 

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 

166 

167 return (src in chars) != (tgt in chars) 

168 

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) 

172 

173 def check_highlight(self, source: str, unit: Unit): 

174 """ 

175 Return parts of the text that match to highlight them. 

176 

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 [] 

181 

182 def get_description(self, check_obj: Check) -> StrOrPromise: 

183 return self.description 

184 

185 def get_fixup(self, unit: Unit) -> Iterable[FixupType] | None: 

186 return None 

187 

188 def render(self, request: AuthenticatedHttpRequest, unit: Unit) -> StrOrPromise: 

189 msg = "Not supported" 

190 raise Http404(msg) 

191 

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 ) 

199 

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") 

207 

208 def noop(content: str) -> str: 

209 return content 

210 

211 flags = unit.all_flags 

212 

213 # chain XML striping if needed 

214 replacement = strip_xml if "xml-text" in flags else noop 

215 

216 if not flags.has_value("replacements"): 

217 return replacement 

218 

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 ) 

225 

226 # Build regexp matcher 

227 pattern = re.compile("|".join(re.escape(key) for key in replacements)) 

228 

229 return lambda text: pattern.sub( 

230 lambda m: replacements[m.group(0)], replacement(text) 

231 ) 

232 

233 

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 

238 

239 def check_component(self, component: Component) -> Iterable[Unit]: 

240 raise NotImplementedError 

241 

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) 

245 

246 def _perform_batch(self, component: Component) -> None: 

247 from weblate.checks.models import Check 

248 from weblate.trans.models import Component 

249 

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) 

258 

259 # Check is already there 

260 if self.check_id in unit.all_checks_names: 

261 continue 

262 

263 create.append(Check(unit=unit, dismissed=False, name=self.check_id)) 

264 components[unit.translation.component.id] = unit.translation.component 

265 

266 Check.objects.bulk_create(create, batch_size=500, ignore_conflicts=True) 

267 

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 

290 

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() 

294 

295 

296class TargetCheck(BaseCheck): 

297 """Basic class for target checks.""" 

298 

299 target = True 

300 

301 def check_source_unit(self, sources: list[str], unit: Unit) -> bool: 

302 """We don't check source strings here.""" 

303 return False 

304 

305 def check_single(self, source: str, target: str, unit: Unit) -> bool: 

306 """Check for single phrase, not dealing with plurals.""" 

307 raise NotImplementedError 

308 

309 def format_value(self, value: str) -> StrOrPromise: 

310 from weblate.trans.templatetags.translations import Formatter 

311 

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 ) 

317 

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 ) 

326 

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 ) 

331 

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 ) 

336 

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 ) 

346 

347 def format_string(self, string: str) -> str: 

348 """Format parsed format string into human readable value.""" 

349 return string 

350 

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)) 

358 

359 

360class SourceCheck(BaseCheck): 

361 """Basic class for source checks.""" 

362 

363 source = True 

364 

365 def check_single(self, source: str, target: str, unit: Unit) -> bool: 

366 """Target strings are checked in check_target_unit.""" 

367 return False 

368 

369 def check_source_unit(self, sources: list[str], unit: Unit) -> bool: 

370 """Check source string.""" 

371 raise NotImplementedError 

372 

373 

374class TargetCheckParametrized(TargetCheck): 

375 """Basic class for target checks with flag value.""" 

376 

377 default_disabled = True 

378 

379 def get_value(self, unit: Unit) -> Any: # noqa: ANN401 

380 return unit.all_flags.get_value(self.enable_string) 

381 

382 def has_value(self, unit: Unit) -> bool: 

383 return unit.all_flags.has_value(self.enable_string) 

384 

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 

394 

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 

403 

404 def check_single(self, source: str, target: str, unit: Unit) -> bool: 

405 """We don't check single phrase here.""" 

406 return False 

407 

408 

409class CountingCheck(TargetCheck): 

410 """Check whether there is same count of given string.""" 

411 

412 string = "" 

413 

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)