Coverage for src / ezqt_app / services / translation / auto_translator.py: 61.72%

234 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-26 07:07 +0000

1# /////////////////////////////////////////////////////////////// 

2# SERVICES.TRANSLATION.AUTO_TRANSLATOR - Automatic translation providers 

3# Project: ezqt_app 

4# /////////////////////////////////////////////////////////////// 

5 

6"""Automatic translation via external providers (disabled by default).""" 

7 

8from __future__ import annotations 

9 

10# /////////////////////////////////////////////////////////////// 

11# IMPORTS 

12# /////////////////////////////////////////////////////////////// 

13# Standard library imports 

14import hashlib 

15import json 

16import threading 

17import time 

18from abc import ABC, abstractmethod 

19from datetime import datetime, timedelta 

20from pathlib import Path 

21from typing import Any 

22 

23# Third-party imports 

24import requests 

25from PySide6.QtCore import QObject, Signal 

26 

27# Local imports 

28from ...utils.diagnostics import warn_tech, warn_user 

29from ...utils.printer import get_printer 

30 

31 

32# /////////////////////////////////////////////////////////////// 

33# CLASSES 

34# /////////////////////////////////////////////////////////////// 

35class TranslationProvider(ABC): 

36 """Base translation provider class""" 

37 

38 def __init__(self, name: str, base_url: str): 

39 self.name = name 

40 self.base_url = base_url 

41 self.timeout = 10 

42 self.rate_limit_delay = 1.0 

43 

44 @abstractmethod 

45 def translate( 

46 self, text: str, source_lang: str, target_lang: str 

47 ) -> str | None: ... 

48 

49 def is_available(self) -> bool: 

50 try: 

51 response = requests.get(self.base_url, timeout=5) 

52 return response.status_code == 200 

53 except Exception: 

54 return False 

55 

56 

57class LibreTranslateProvider(TranslationProvider): 

58 """LibreTranslate provider""" 

59 

60 def __init__(self, api_key: str | None = None, custom_server: str | None = None): 

61 server = custom_server or "https://libretranslate.com" 

62 super().__init__("LibreTranslate", server) 

63 self.api_key = api_key 

64 self.rate_limit_delay = 1.0 

65 

66 def translate(self, text: str, source_lang: str, target_lang: str) -> str | None: 

67 try: 

68 url = f"{self.base_url}/translate" 

69 data: dict[str, Any] = { 

70 "q": text, 

71 "source": source_lang, 

72 "target": target_lang, 

73 "format": "text", 

74 } 

75 if self.api_key: 75 ↛ 76line 75 didn't jump to line 76 because the condition on line 75 was never true

76 data["api_key"] = self.api_key 

77 

78 response = requests.post( 

79 url, 

80 json=data, 

81 headers={"Content-Type": "application/json"}, 

82 timeout=self.timeout, 

83 ) 

84 if response.status_code == 200: 84 ↛ 86line 84 didn't jump to line 86 because the condition on line 84 was always true

85 return response.json().get("translatedText") 

86 if self.base_url == "https://libretranslate.com": 

87 warn_user( 

88 code="translation.provider.libretranslate.http_error", 

89 user_message=( 

90 f"LibreTranslate error: {response.status_code}, " 

91 "trying alternative server" 

92 ), 

93 log_message=( 

94 f"LibreTranslate error: {response.status_code}, " 

95 "trying alternative server" 

96 ), 

97 ) 

98 return LibreTranslateProvider( 

99 custom_server="https://translate.argosopentech.com" 

100 ).translate(text, source_lang, target_lang) 

101 warn_tech( 

102 code="translation.provider.libretranslate.http_error", 

103 message=f"LibreTranslate error: {response.status_code}", 

104 ) 

105 return None 

106 except Exception as e: 

107 warn_tech( 

108 code="translation.provider.libretranslate.exception", 

109 message="LibreTranslate exception", 

110 error=e, 

111 ) 

112 return None 

113 

114 

115class GoogleTranslateProvider(TranslationProvider): 

116 """Google Translate Web provider (unofficial)""" 

117 

118 def __init__(self): 

119 super().__init__("Google Translate", "https://translate.googleapis.com") 

120 self.rate_limit_delay = 0.5 

121 

122 def translate(self, text: str, source_lang: str, target_lang: str) -> str | None: 

123 try: 

124 response = requests.get( 

125 f"{self.base_url}/translate_a/single", 

126 params={ 

127 "client": "gtx", 

128 "sl": source_lang, 

129 "tl": target_lang, 

130 "dt": "t", 

131 "q": text, 

132 }, 

133 timeout=self.timeout, 

134 ) 

135 if response.status_code == 200: 

136 data = response.json() 

137 if data and len(data) > 0 and len(data[0]) > 0: 137 ↛ 150line 137 didn't jump to line 150 because the condition on line 137 was always true

138 return data[0][0][0] 

139 else: 

140 warn_tech( 

141 code="translation.provider.google.http_error", 

142 message=f"Google Translate error: {response.status_code}", 

143 ) 

144 except Exception as e: 

145 warn_tech( 

146 code="translation.provider.google.exception", 

147 message="Google Translate exception", 

148 error=e, 

149 ) 

150 return None 

151 

152 

153class MyMemoryProvider(TranslationProvider): 

154 """MyMemory provider (free, no API key required)""" 

155 

156 def __init__(self): 

157 super().__init__("MyMemory", "https://api.mymemory.translated.net") 

158 

159 def translate(self, text: str, source_lang: str, target_lang: str) -> str | None: 

160 try: 

161 response = requests.get( 

162 f"{self.base_url}/get", 

163 params={"q": text, "langpair": f"{source_lang}|{target_lang}"}, 

164 timeout=self.timeout, 

165 ) 

166 if response.status_code == 200: 166 ↛ 171line 166 didn't jump to line 171 because the condition on line 166 was always true

167 data = response.json() 

168 if data.get("responseStatus") == 200: 

169 return data.get("responseData", {}).get("translatedText") 

170 else: 

171 warn_tech( 

172 code="translation.provider.mymemory.http_error", 

173 message=f"MyMemory error: {response.status_code}", 

174 ) 

175 except Exception as e: 

176 warn_tech( 

177 code="translation.provider.mymemory.exception", 

178 message="MyMemory exception", 

179 error=e, 

180 ) 

181 return None 

182 

183 

184class TranslationCache: 

185 """Translation cache manager""" 

186 

187 def __init__(self, cache_file: Path): 

188 self.cache_file = cache_file 

189 self.cache_data: dict[str, Any] = {} 

190 self.max_age_days = 30 

191 self.load_cache() 

192 

193 def _get_cache_key(self, text: str, source_lang: str, target_lang: str) -> str: 

194 return hashlib.md5( 

195 f"{text}|{source_lang}|{target_lang}".encode(), usedforsecurity=False 

196 ).hexdigest() 

197 

198 def get(self, text: str, source_lang: str, target_lang: str) -> str | None: 

199 key = self._get_cache_key(text, source_lang, target_lang) 

200 entry = self.cache_data.get(key) 

201 if entry: 

202 created_time = datetime.fromisoformat(entry["created"]) 

203 if datetime.now() - created_time < timedelta(days=self.max_age_days): 

204 return entry["translation"] 

205 del self.cache_data[key] 

206 return None 

207 

208 def set( 

209 self, 

210 text: str, 

211 source_lang: str, 

212 target_lang: str, 

213 translation: str, 

214 provider: str, 

215 ) -> None: 

216 key = self._get_cache_key(text, source_lang, target_lang) 

217 self.cache_data[key] = { 

218 "original": text, 

219 "translation": translation, 

220 "source_lang": source_lang, 

221 "target_lang": target_lang, 

222 "provider": provider, 

223 "created": datetime.now().isoformat(), 

224 } 

225 self.save_cache() 

226 

227 def load_cache(self) -> None: 

228 try: 

229 if self.cache_file.exists(): 

230 with open(self.cache_file, encoding="utf-8") as f: 

231 self.cache_data = json.load(f) 

232 except Exception as e: 

233 warn_tech( 

234 code="translation.cache.load_failed", 

235 message="Error loading cache", 

236 error=e, 

237 ) 

238 self.cache_data = {} 

239 

240 def save_cache(self) -> None: 

241 try: 

242 self.cache_file.parent.mkdir(parents=True, exist_ok=True) 

243 with open(self.cache_file, "w", encoding="utf-8") as f: 

244 json.dump(self.cache_data, f, indent=2, ensure_ascii=False) 

245 except Exception as e: 

246 warn_tech( 

247 code="translation.cache.save_failed", 

248 message="Error saving cache", 

249 error=e, 

250 ) 

251 

252 def clear_expired(self) -> None: 

253 current_time = datetime.now() 

254 expired_keys = [ 

255 key 

256 for key, entry in self.cache_data.items() 

257 if current_time - datetime.fromisoformat(entry["created"]) 

258 > timedelta(days=self.max_age_days) 

259 ] 

260 for key in expired_keys: 

261 del self.cache_data[key] 

262 if expired_keys: 

263 self.save_cache() 

264 

265 

266class AutoTranslator(QObject): 

267 """Automatic translation manager (disabled by default). 

268 

269 Each call to :meth:`translate` spawns a lightweight daemon thread that 

270 performs the HTTP round-trip in the background. Signals are emitted from 

271 that thread; Qt automatically delivers them via a queued connection to 

272 slots that live in the main thread, so the UI is never blocked. 

273 

274 A ``_pending`` set (guarded by ``_lock``) deduplicates in-flight requests: 

275 if the same source string is requested while a thread for it is still 

276 running, no second thread is spawned. 

277 """ 

278 

279 translation_ready = Signal(str, str) 

280 translation_error = Signal(str, str) 

281 

282 def __init__(self, cache_dir: Path | None = None): 

283 super().__init__() 

284 if cache_dir is None: 

285 cache_dir = Path.home() / ".ezqt" / "cache" 

286 cache_dir.mkdir(parents=True, exist_ok=True) 

287 self.cache = TranslationCache(cache_dir / "translations.json") 

288 self.providers: list[TranslationProvider] = [] 

289 self._pending: set[str] = set() 

290 self._lock = threading.Lock() 

291 self._setup_providers() 

292 self.enabled = False 

293 

294 def _setup_providers(self) -> None: 

295 # Order matters: fastest/unofficial first, then free fallback providers. 

296 # is_available() performs a synchronous HTTP GET; calling it at setup time 

297 # would block the Qt main thread. Provider availability checking requires 

298 # a dedicated health-check mechanism before it can be integrated. 

299 self.providers = [ 

300 GoogleTranslateProvider(), 

301 MyMemoryProvider(), 

302 LibreTranslateProvider(), 

303 ] 

304 

305 def add_provider(self, provider: TranslationProvider) -> None: 

306 self.providers.append(provider) 

307 

308 def remove_provider(self, provider_name: str) -> None: 

309 self.providers = [p for p in self.providers if p.name != provider_name] 

310 

311 def translate(self, text: str, source_lang: str, target_lang: str) -> str | None: 

312 """Schedule an async translation and return ``None`` immediately. 

313 

314 If *source_lang* equals *target_lang* the method returns *text* 

315 immediately (identity translation — no HTTP request is made). 

316 If *text* is already cached the cached value is returned immediately. 

317 Otherwise a daemon thread is started and ``None`` is returned; the 

318 caller receives the result via :attr:`translation_ready`. 

319 """ 

320 if source_lang == target_lang: 

321 return text 

322 

323 cached = self.cache.get(text, source_lang, target_lang) 

324 if cached: 

325 return cached 

326 

327 with self._lock: 

328 if text in self._pending: 

329 return None 

330 self._pending.add(text) 

331 

332 t = threading.Thread( 

333 target=self._do_translate, 

334 args=(text, source_lang, target_lang), 

335 daemon=True, 

336 name=f"ez-translate:{text[:30]}", 

337 ) 

338 t.start() 

339 return None 

340 

341 def _do_translate(self, text: str, source_lang: str, target_lang: str) -> None: 

342 """Blocking translation worker — runs in a background daemon thread.""" 

343 try: 

344 for provider in self.providers: 

345 try: 

346 translation = provider.translate(text, source_lang, target_lang) 

347 if translation: 

348 self.cache.set( 

349 text, source_lang, target_lang, translation, provider.name 

350 ) 

351 get_printer().debug_msg( 

352 "[TranslationService] Automatic translation " 

353 f"({provider.name}): '{text}' -> '{translation}'" 

354 ) 

355 # Signal is delivered to the main thread via queued connection. 

356 self.translation_ready.emit(text, translation) 

357 return 

358 time.sleep(provider.rate_limit_delay) 

359 except Exception as e: 

360 warn_tech( 

361 code="translation.worker.provider_failed", 

362 message=f"Translation error with {provider.name}", 

363 error=e, 

364 ) 

365 

366 warn_user( 

367 code="translation.auto.failed", 

368 user_message=f"Automatic translation failed: '{text}'", 

369 log_message=f"All providers failed for '{text}'", 

370 ) 

371 self.translation_error.emit(text, "No translation found") 

372 finally: 

373 with self._lock: 

374 self._pending.discard(text) 

375 

376 def translate_sync( 

377 self, text: str, source_lang: str, target_lang: str 

378 ) -> str | None: 

379 """Translate text synchronously, blocking until a result is obtained. 

380 

381 Intended for use in CLI scripts, test helpers, and offline batch-processing 

382 tools that run outside the Qt event loop. Each provider call is a blocking 

383 HTTP request; the total wait time can reach ``len(providers) × timeout`` 

384 seconds if all providers fail. 

385 

386 Warning: 

387 **Never call this method from the Qt main (UI) thread.** Doing so 

388 blocks the event loop for the entire duration of the HTTP round-trips, 

389 freezing the application UI. For in-app translation use 

390 :meth:`translate` instead, which runs the request in a daemon thread. 

391 

392 Example:: 

393 

394 # Appropriate usage — called from a CLI script, not from a Qt slot: 

395 translator = get_auto_translator() 

396 translator.enabled = True 

397 result = translator.translate_sync("Hello", "en", "fr") 

398 print(result) # "Bonjour" 

399 

400 Args: 

401 text: The source text to translate. 

402 source_lang: BCP-47 language code of the source text (e.g. ``"en"``). 

403 target_lang: BCP-47 language code of the desired output (e.g. ``"fr"``). 

404 

405 Returns: 

406 The translated string, or ``None`` if the translator is disabled or 

407 all providers fail. 

408 """ 

409 if not self.enabled: 

410 return None 

411 

412 cached = self.cache.get(text, source_lang, target_lang) 

413 if cached: 

414 return cached 

415 

416 for provider in self.providers: 416 ↛ 432line 416 didn't jump to line 432 because the loop on line 416 didn't complete

417 try: 

418 translation = provider.translate(text, source_lang, target_lang) 

419 if translation: 419 ↛ 424line 419 didn't jump to line 424 because the condition on line 419 was always true

420 self.cache.set( 

421 text, source_lang, target_lang, translation, provider.name 

422 ) 

423 return translation 

424 time.sleep(provider.rate_limit_delay) 

425 except Exception as e: 

426 warn_tech( 

427 code="translation.sync.provider_failed", 

428 message=f"Translation error with {provider.name}", 

429 error=e, 

430 ) 

431 

432 return None 

433 

434 def save_translation_to_ts( 

435 self, original: str, translated: str, target_lang: str, ts_file_path: Path 

436 ) -> None: 

437 """Append a single translation entry to a Qt Linguist .ts XML file.""" 

438 import xml.etree.ElementTree as ET 

439 

440 try: 

441 if ts_file_path.exists(): 

442 try: 

443 tree = ET.parse(ts_file_path) # noqa: S314 

444 root = tree.getroot() 

445 except ET.ParseError: 

446 root = ET.Element("TS", {"language": target_lang, "version": "2.1"}) 

447 tree = ET.ElementTree(root) 

448 else: 

449 root = ET.Element("TS", {"language": target_lang, "version": "2.1"}) 

450 tree = ET.ElementTree(root) 

451 

452 context = root.find("context") 

453 if context is None: 

454 context = ET.SubElement(root, "context") 

455 ET.SubElement(context, "name").text = "ezqt_app" 

456 

457 # Update existing entry if source already present, otherwise append. 

458 for msg in context.findall("message"): 

459 src = msg.find("source") 

460 if src is not None and src.text == original: 

461 trans = msg.find("translation") 

462 if trans is not None: 

463 trans.text = translated 

464 break 

465 else: 

466 msg = ET.SubElement(context, "message") 

467 ET.SubElement(msg, "source").text = original 

468 ET.SubElement(msg, "translation").text = translated 

469 

470 ts_file_path.parent.mkdir(parents=True, exist_ok=True) 

471 tree.write(ts_file_path, encoding="unicode", xml_declaration=True) 

472 get_printer().debug_msg( 

473 f"[TranslationService] Translation saved to {ts_file_path}" 

474 ) 

475 except Exception as e: 

476 warn_tech( 

477 code="translation.ts.save_failed", 

478 message="Error saving translation to .ts file", 

479 error=e, 

480 ) 

481 

482 def clear_cache(self) -> None: 

483 self.cache.cache_data.clear() 

484 self.cache.save_cache() 

485 get_printer().debug_msg("[TranslationService] Translation cache cleared") 

486 

487 def get_cache_stats(self) -> dict[str, Any]: 

488 stats: dict[str, Any] = { 

489 "total_entries": len(self.cache.cache_data), 

490 "cache_file": str(self.cache.cache_file), 

491 "max_age_days": self.cache.max_age_days, 

492 } 

493 provider_stats: dict[str, int] = {} 

494 for entry in self.cache.cache_data.values(): 

495 p = entry.get("provider", "unknown") 

496 provider_stats[p] = provider_stats.get(p, 0) + 1 

497 stats["by_provider"] = provider_stats 

498 return stats 

499 

500 def cleanup(self) -> None: 

501 # Background threads are daemon threads — they exit automatically when 

502 # the process exits. We only need to flush the on-disk cache. 

503 self.cache.clear_expired() 

504 

505 

506# /////////////////////////////////////////////////////////////// 

507# FUNCTIONS 

508# /////////////////////////////////////////////////////////////// 

509def get_auto_translator() -> AutoTranslator: 

510 """Return the global AutoTranslator singleton.""" 

511 from .._registry import ServiceRegistry 

512 

513 return ServiceRegistry.get(AutoTranslator, AutoTranslator)