Coverage for app/venv/lib/python3.14/site-packages/weblate/trans/autotranslate.py: 14%

152 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 

7from typing import TYPE_CHECKING, Literal 

8 

9from celery import current_task 

10from django.conf import settings 

11from django.core.exceptions import PermissionDenied 

12from django.db import transaction 

13from django.db.models import Value 

14from django.db.models.functions import MD5, Lower 

15from django.utils.translation import gettext, ngettext 

16 

17from weblate.machinery.base import ( 

18 MachineTranslationError, 

19) 

20from weblate.machinery.models import MACHINERY 

21from weblate.trans.actions import ActionEvents 

22from weblate.trans.models import Component, Suggestion, Unit 

23from weblate.trans.util import split_plural 

24from weblate.utils.state import ( 

25 STATE_APPROVED, 

26 STATE_FUZZY, 

27 STATE_READONLY, 

28 STATE_TRANSLATED, 

29) 

30 

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

32 from weblate.auth.models import User 

33 from weblate.machinery.base import ( 

34 BatchMachineTranslation, 

35 UnitMemoryResultDict, 

36 ) 

37 from weblate.trans.models import Translation 

38 from weblate.utils.state import ( 

39 StringState, 

40 ) 

41 

42 

43class AutoTranslate: 

44 def __init__( 

45 self, 

46 *, 

47 user: User | None, 

48 translation: Translation, 

49 q: str, 

50 mode: str, 

51 component_wide: bool = False, 

52 unit_ids: list[int] | None = None, 

53 ) -> None: 

54 self.user: User | None = user 

55 self.translation: Translation = translation 

56 translation.component.batch_checks = True 

57 

58 self.unit_ids: list[int] | None = unit_ids 

59 

60 self.q: str = q 

61 self.mode: str = mode 

62 self.updated = 0 

63 self.progress_steps = 0 

64 self.progress_base = 0 

65 self.target_state = STATE_TRANSLATED 

66 if mode == "fuzzy": 

67 self.target_state = STATE_FUZZY 

68 elif mode == "approved" and translation.enable_review: 

69 self.target_state = STATE_APPROVED 

70 self.component_wide: bool = component_wide 

71 

72 def get_units(self): 

73 units = self.translation.unit_set.exclude(state=STATE_READONLY) 

74 if self.unit_ids is not None: 

75 units = units.filter(pk__in=self.unit_ids) 

76 if self.mode == "suggest": 

77 units = units.filter(suggestion__isnull=True) 

78 return units.search(self.q, parser="unit") 

79 

80 def set_progress(self, current) -> None: 

81 if current_task and current_task.request.id and self.progress_steps: 

82 current_task.update_state( 

83 state="PROGRESS", 

84 meta={ 

85 "progress": 100 * current // self.progress_steps, 

86 "translation": self.translation.pk, 

87 }, 

88 ) 

89 

90 def update( 

91 self, unit: Unit, state: StringState, target: list[str], user=None 

92 ) -> None: 

93 if isinstance(target, str): 

94 target = [target] 

95 max_length = unit.get_max_length() 

96 if self.mode == "suggest" or any(len(item) > max_length for item in target): 

97 suggestion = Suggestion.objects.add( 

98 unit, target, request=None, vote=False, user=user, raise_exception=False 

99 ) 

100 if suggestion: 

101 self.updated += 1 

102 else: 

103 unit.is_batch_update = True 

104 unit.translate( 

105 user or self.user, 

106 target, 

107 state, 

108 change_action=ActionEvents.AUTO, 

109 propagate=False, 

110 ) 

111 self.updated += 1 

112 

113 def post_process(self) -> None: 

114 if self.updated > 0: 

115 self.translation.log_info("finalizing automatic translation") 

116 if not self.component_wide: 

117 self.translation.component.update_source_checks() 

118 self.translation.component.run_batched_checks() 

119 self.translation.invalidate_cache() 

120 if self.user: 

121 self.user.profile.increase_count("translated", self.updated) 

122 

123 @transaction.atomic 

124 def process_others(self, source: int | None) -> None: 

125 """Perform automatic translation based on other components.""" 

126 kwargs = { 

127 "translation__plural": self.translation.plural, 

128 "state__gte": STATE_TRANSLATED, 

129 } 

130 source_language = self.translation.component.source_language 

131 exclude = {} 

132 if source: 

133 component = Component.objects.get(id=source) 

134 

135 if ( 

136 not component.project.contribute_shared_tm 

137 and component.project != self.translation.component.project 

138 ): 

139 msg = "Project has disabled contribution to shared translation memory." 

140 raise PermissionDenied(msg) 

141 if component.source_language != source_language: 

142 msg = "Component have different source languages." 

143 raise PermissionDenied(msg) 

144 kwargs["translation__component"] = component 

145 else: 

146 project = self.translation.component.project 

147 kwargs["translation__component__project"] = project 

148 kwargs["translation__component__source_language"] = source_language 

149 exclude["translation"] = self.translation 

150 sources = Unit.objects.filter(**kwargs) 

151 

152 # Use memory_db for the query in case it exists. This is supposed 

153 # to be a read-only replica for offloading expensive translation 

154 # queries. 

155 if "memory_db" in settings.DATABASES: 

156 sources = sources.using("memory_db") 

157 

158 if exclude: 

159 sources = sources.exclude(**exclude) 

160 

161 # Get source MD5s 

162 source_md5s = list( 

163 self.get_units() 

164 .annotate(source__lower__md5=MD5(Lower("source"))) 

165 .values_list("source__lower__md5", flat=True) 

166 ) 

167 

168 # Fetch available translations 

169 translations = { 

170 source: split_plural(target) 

171 for source, target in sources.filter( 

172 source__lower__md5__in=source_md5s 

173 ).values_list("source", "target") 

174 } 

175 

176 # Fetch translated unit IDs 

177 # Cannot use get_units() directly as SELECT FOR UPDATE cannot be used with JOIN 

178 unit_ids = list( 

179 self.get_units() 

180 .filter( 

181 source__lower__md5__in=[ 

182 MD5(Lower(Value(translation))) for translation in translations 

183 ] 

184 ) 

185 .values_list("id", flat=True) 

186 ) 

187 units = ( 

188 Unit.objects.filter(pk__in=unit_ids) 

189 .prefetch() 

190 .prefetch_bulk() 

191 .select_for_update() 

192 ) 

193 self.progress_steps = len(units) 

194 

195 for pos, unit in enumerate(units): 

196 # Get update 

197 try: 

198 target = translations[unit.source] 

199 except KeyError: 

200 # Happens due to case-insensitive lookup 

201 continue 

202 

203 self.set_progress(pos) 

204 

205 # No save if translation is same or unit does not exist 

206 if unit.state == self.target_state and unit.target == target: 

207 continue 

208 # Copy translation 

209 self.update(unit, self.target_state, target) 

210 

211 self.post_process() 

212 

213 def fetch_mt( 

214 self, engines_list: list[str], threshold: int 

215 ) -> dict[int, UnitMemoryResultDict]: 

216 """Get the translations.""" 

217 units: list[Unit] = list(self.get_units().select_related("source_unit")) 

218 num_units = len(units) 

219 

220 machinery_settings = self.translation.component.project.get_machinery_settings() 

221 

222 engines: list[BatchMachineTranslation] = sorted( 

223 ( 

224 MACHINERY[engine](setting) 

225 for engine, setting in machinery_settings.items() 

226 if engine in MACHINERY and engine in engines_list 

227 ), 

228 key=lambda engine: engine.get_rank(), 

229 reverse=True, 

230 ) 

231 

232 self.progress_base = len(engines) * num_units 

233 # Estimate number of strings to translate, this is adjusted in process_mt 

234 self.progress_steps = self.progress_base + num_units 

235 

236 for pos, translation_service in enumerate(engines): 

237 batch_size = translation_service.batch_size 

238 self.translation.log_info( 

239 "fetching translations for %d units from %s, %d per request", 

240 num_units, 

241 translation_service.name, 

242 batch_size, 

243 ) 

244 

245 for batch_start in range(0, num_units, batch_size): 

246 self.set_progress(pos * num_units + batch_start) 

247 try: 

248 translation_service.batch_translate( 

249 units[batch_start : batch_start + batch_size], 

250 self.user, 

251 threshold=threshold, 

252 ) 

253 except MachineTranslationError as error: 

254 # Ignore errors here to complete fetching 

255 self.translation.log_error( 

256 "failed automatic translation: %s", error 

257 ) 

258 

259 self.set_progress(self.progress_base) 

260 return { 

261 unit.id: unit.machinery 

262 for unit in units 

263 if unit.machinery and any(unit.machinery["quality"]) 

264 } 

265 

266 def process_mt(self, engines: list[str], threshold: int) -> None: 

267 """Perform automatic translation based on machine translation.""" 

268 translations = self.fetch_mt(engines, int(threshold)) 

269 

270 # Adjust total number to show correct progress 

271 self.progress_steps = self.progress_base + len(translations) 

272 

273 with transaction.atomic(): 

274 # Perform the translation 

275 self.translation.log_info("updating %d strings", len(translations)) 

276 for pos, unit in enumerate( 

277 self.translation.unit_set.filter(id__in=translations.keys()) 

278 .prefetch_bulk() 

279 .select_for_update() 

280 ): 

281 translation: UnitMemoryResultDict = translations[unit.pk] 

282 # Use first existing origin for user 

283 # (there can be blanks for missing plurals) 

284 user: User | None = None 

285 for origin in translation["origin"]: 

286 if origin is not None: 

287 user = origin.user 

288 break 

289 # Copy translation 

290 self.update( 

291 unit, 

292 self.target_state, 

293 translation["translation"], 

294 user=user, 

295 ) 

296 self.set_progress(self.progress_base + pos + 1) 

297 

298 self.post_process() 

299 

300 def perform( 

301 self, 

302 *, 

303 auto_source: Literal["mt", "others"], 

304 engines: list[str], 

305 threshold: int, 

306 source: int | None, 

307 ) -> str: 

308 translation = self.translation 

309 translation.log_info( 

310 "starting automatic translation (%s) %s: %s: %s", 

311 self.mode, 

312 current_task.request.id if current_task and current_task.request.id else "", 

313 auto_source, 

314 ", ".join(engines) if engines else source, 

315 ) 

316 try: 

317 if auto_source == "mt": 

318 self.process_mt(engines, threshold) 

319 else: 

320 self.process_others(source) 

321 except (MachineTranslationError, Component.DoesNotExist) as error: 

322 translation.log_error("failed automatic translation: %s", error) 

323 return gettext("Automatic translation failed: %s") % error 

324 

325 translation.log_info("completed automatic translation") 

326 

327 if self.updated == 0: 

328 return gettext("Automatic translation completed, no strings were updated.") 

329 return ( 

330 ngettext( 

331 "Automatic translation completed, %d string was updated.", 

332 "Automatic translation completed, %d strings were updated.", 

333 self.updated, 

334 ) 

335 % self.updated 

336 )