Coverage for src / ezqt_app / services / translation / manager.py: 58.81%
258 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-06 13:12 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-06 13:12 +0000
1# ///////////////////////////////////////////////////////////////
2# SERVICES.TRANSLATION.MANAGER - Translation engine
3# Project: ezqt_app
4# ///////////////////////////////////////////////////////////////
6"""Core translation manager — QTranslator wrapper with Qt-native language change propagation."""
8from __future__ import annotations
10# ///////////////////////////////////////////////////////////////
11# IMPORTS
12# ///////////////////////////////////////////////////////////////
13# Standard library imports
14import sys
15from pathlib import Path
16from typing import Any
18# Third-party imports
19from PySide6.QtCore import QCoreApplication, QObject, QTranslator, Signal
21# Local imports
22from ...domain.models.translation import DEFAULT_LANGUAGE, SUPPORTED_LANGUAGES
23from ...services.config import get_config_service
24from ...utils.diagnostics import warn_tech, warn_user
25from ...utils.printer import get_printer
26from ...utils.runtime_paths import get_bin_path
27from .auto_translator import get_auto_translator
30# ///////////////////////////////////////////////////////////////
31# HELPERS
32# ///////////////////////////////////////////////////////////////
33def _parse_bool(raw: object) -> bool:
34 """Parse a config value as bool, supporting string representations."""
35 if isinstance(raw, str): 35 ↛ 36line 35 didn't jump to line 36 because the condition on line 35 was never true
36 return raw.strip().lower() in {"1", "true", "yes", "on"}
37 return bool(raw)
40# ///////////////////////////////////////////////////////////////
41# CLASSES
42# ///////////////////////////////////////////////////////////////
43class EzTranslator(QTranslator):
44 """Qt translator that intercepts unknown strings for auto-translation.
46 This translator is installed alongside the compiled ``.qm`` translator.
47 Qt calls ``translate()`` on every installed translator in LIFO order until
48 one returns a non-empty string. When *source_text* is not yet known,
49 ``EzTranslator`` fires the async auto-translation pipeline and returns an
50 empty string so Qt falls back to the source text for this render cycle.
51 On the next ``LanguageChange`` (triggered once the translation arrives) the
52 string is found in ``_ts_translations`` and returned immediately.
54 Args:
55 manager: The owning :class:`TranslationManager` instance.
56 """
58 _CONTEXT = "EzQt_App"
60 def __init__(self, manager: TranslationManager) -> None:
61 super().__init__()
62 self._manager = manager
64 # ------------------------------------------------------------------
65 # QTranslator virtual override
66 # ------------------------------------------------------------------
68 def translate(
69 self,
70 context: str,
71 source_text: str,
72 disambiguation: str | None = None, # noqa: ARG002
73 n: int = -1, # noqa: ARG002
74 ) -> str | None: # type: ignore[override] # PySide6 maps None → null QString → Qt falls back
75 """Return the translation for *source_text*, or ``None`` if unknown.
77 Returning ``None`` signals Qt (via null QString) that this translator
78 did not handle the string, so Qt continues querying the next installed
79 translator and ultimately falls back to the source text.
81 Returning ``""`` would mean "found, but translation is empty", which
82 would incorrectly replace every untranslated string with an empty label.
84 When the string is absent from the in-memory cache and auto-translation
85 is active, an async translation request is fired so the string will be
86 available on the next ``LanguageChange`` cycle.
88 Args:
89 context: Qt translation context (only ``"EzQt_App"`` is handled).
90 source_text: The English source string to translate.
91 disambiguation: Optional disambiguation hint (unused).
92 n: Plural form selector (unused).
94 Returns:
95 The translated string if cached, ``None`` otherwise.
96 """
97 if context != self._CONTEXT or not source_text:
98 return None
100 cached = self._manager._ts_translations.get(source_text)
101 if cached:
102 return cached
104 # String is unknown — fire auto-translation if enabled.
105 # auto_translator.translate() handles all cases uniformly:
106 # • source == target → returns text immediately (identity, no HTTP)
107 # • cache hit → returns cached value immediately
108 # • cache miss → spawns a daemon thread, returns None
109 # All languages including English are processed so that every .ts file
110 # is populated with real entries and compiled to a valid .qm.
111 if ( 111 ↛ 115line 111 didn't jump to line 115 because the condition on line 111 was never true
112 self._manager.auto_translation_enabled
113 and self._manager.auto_translator.enabled
114 ):
115 result = self._manager.auto_translator.translate(
116 source_text, DEFAULT_LANGUAGE, self._manager.current_language
117 )
118 if result is not None:
119 # Immediate result (identity or cache hit): populate in-memory
120 # cache and persist to .ts so the string survives the next load.
121 self._manager._ts_translations[source_text] = result
122 self._manager._persist_translation(source_text, result)
123 return result
124 # Async request dispatched — a thread is running the HTTP round-trip.
125 self._manager._increment_pending()
127 # Null return → Qt falls back to the .qm translator or source text.
128 return None
131class TranslationManager(QObject):
132 """Core translation engine for EzQt_App.
134 Handles .ts file loading, language switching, Qt translator installation
135 and automatic widget retranslation on language change.
136 """
138 languageChanged = Signal(str)
139 # Emitted when the first pending auto-translation is enqueued (count > 0).
140 # Consumers (e.g. BottomBar) use this to show a progress indicator.
141 translation_started = Signal()
142 # Emitted when all pending auto-translations have been resolved (count == 0).
143 # Consumers use this to hide the progress indicator.
144 translation_finished = Signal()
146 def __init__(self) -> None:
147 super().__init__()
148 self.translator = QTranslator()
149 self.current_language = DEFAULT_LANGUAGE
151 self._ts_translations: dict[str, str] = {}
153 # Count of async auto-translation requests that have been fired but
154 # for which no result (ready or error) has arrived yet. Incremented
155 # each time a new request is dispatched; decremented on every outcome.
156 # When the count transitions 0 → 1 we emit translation_started; when
157 # it transitions 1 → 0 we emit translation_finished.
158 self._pending_auto_translations: int = 0
160 # EzTranslator intercepts QCoreApplication.translate("EzQt_App", text)
161 # calls for strings absent from the compiled .qm file (e.g. developer
162 # custom strings not yet added to the .ts). It is installed once and
163 # kept across language switches; only the .qm-backed translator is
164 # swapped on each language change.
165 self._ez_translator = EzTranslator(self)
167 self.auto_translator = get_auto_translator()
168 self.auto_translation_enabled = False
169 self.auto_save_translations = False
171 try:
172 translation_cfg = (
173 get_config_service().load_config("translation").get("translation", {})
174 )
175 if isinstance(translation_cfg, dict): 175 ↛ 189line 175 didn't jump to line 189 because the condition on line 175 was always true
176 self.auto_translation_enabled = _parse_bool(
177 translation_cfg.get("auto_translation_enabled", False)
178 )
179 self.auto_save_translations = _parse_bool(
180 translation_cfg.get("save_to_ts_files", False)
181 )
182 except Exception as e:
183 warn_tech(
184 code="translation.manager.config_load_failed",
185 message="Could not load translation config; using defaults (disabled)",
186 error=e,
187 )
189 self.auto_translator.enabled = self.auto_translation_enabled
190 self.auto_translator.translation_ready.connect(self._on_auto_translation_ready)
191 self.auto_translator.translation_error.connect(self._on_auto_translation_error)
193 # Register cleanup on application exit: stop the worker thread and purge
194 # the expired cache entries. Guard against QCoreApplication not yet existing
195 # (e.g., when the manager is instantiated in a test context without a Qt app).
196 # Also install EzTranslator so it begins intercepting QCoreApplication.translate()
197 # calls immediately — even before the first explicit language switch.
198 _qapp = QCoreApplication.instance()
199 if _qapp is not None: 199 ↛ 204line 199 didn't jump to line 204 because the condition on line 199 was always true
200 _qapp.aboutToQuit.connect(self.auto_translator.cleanup)
201 QCoreApplication.installTranslator(self._ez_translator)
203 # Resolve translations directory
204 if hasattr(sys, "_MEIPASS"): 204 ↛ 205line 204 didn't jump to line 205 because the condition on line 204 was never true
205 self.translations_dir: Path | None = (
206 Path(sys._MEIPASS) # pyright: ignore[reportAttributeAccessIssue]
207 / "ezqt_app"
208 / "resources"
209 / "translations"
210 )
211 else:
212 possible_paths = [
213 get_bin_path() / "translations",
214 Path(__file__).parent.parent.parent / "resources" / "translations",
215 ]
216 try:
217 pkg_dir = self._get_package_translations_dir()
218 if pkg_dir.exists(): 218 ↛ 227line 218 didn't jump to line 227 because the condition on line 218 was always true
219 possible_paths.append(pkg_dir)
220 except Exception as e:
221 warn_tech(
222 code="translation.manager.package_dir_resolution_failed",
223 message="Could not resolve package translations dir",
224 error=e,
225 )
227 self.translations_dir = next(
228 (p for p in possible_paths if p.exists()), None
229 )
230 if self.translations_dir is None: 230 ↛ 231line 230 didn't jump to line 231 because the condition on line 230 was never true
231 self.translations_dir = get_bin_path() / "translations"
232 self.translations_dir.mkdir(parents=True, exist_ok=True)
234 def _get_package_translations_dir(self) -> Path:
235 try:
236 from importlib.resources import files
238 return Path(str(files("ezqt_app").joinpath("resources/translations")))
239 except Exception:
240 return Path(__file__).parent.parent.parent / "resources" / "translations"
242 # ------------------------------------------------------------------
243 # .qm compilation helpers
244 # ------------------------------------------------------------------
246 def _find_lrelease(self) -> Path | None:
247 """Locate the pyside6-lrelease executable.
249 Searches PATH first (the ``pyside6-lrelease`` wrapper script), then
250 falls back to the ``lrelease[.exe]`` binary inside the PySide6 package
251 directory. Returns ``None`` if the tool cannot be found.
252 """
253 import shutil
255 which = shutil.which("pyside6-lrelease")
256 if which: 256 ↛ 259line 256 didn't jump to line 259 because the condition on line 256 was always true
257 return Path(which)
259 try:
260 import PySide6
262 pyside6_dir = Path(PySide6.__file__).parent
263 for name in ("lrelease.exe", "lrelease"):
264 candidate = pyside6_dir / name
265 if candidate.exists():
266 return candidate
267 except Exception as e:
268 warn_tech(
269 code="translation.manager.pyside6_lrelease_lookup_failed",
270 message="Could not locate lrelease in PySide6 package directory",
271 error=e,
272 )
274 return None
276 def _ensure_qm_compiled(self, ts_path: Path, qm_path: Path) -> bool:
277 """Compile *ts_path* → *qm_path* using ``pyside6-lrelease`` if needed.
279 Skips recompilation when *qm_path* is already up-to-date (mtime ≥
280 *ts_path* mtime). Returns ``True`` if *qm_path* exists after the
281 method completes (either freshly compiled or already current).
282 """
283 if qm_path.exists() and qm_path.stat().st_mtime >= ts_path.stat().st_mtime:
284 return True
286 lrelease = self._find_lrelease()
287 if lrelease is None: 287 ↛ 288line 287 didn't jump to line 288 because the condition on line 287 was never true
288 warn_tech(
289 code="translation.manager.lrelease_not_found",
290 message=(
291 "pyside6-lrelease not found; .qm file will not be generated. "
292 "Install PySide6 tools or ensure pyside6-lrelease is on PATH."
293 ),
294 )
295 return qm_path.exists()
297 try:
298 import subprocess # nosec B404
300 result = subprocess.run( # nosec B603
301 [str(lrelease), str(ts_path), "-qm", str(qm_path)],
302 capture_output=True,
303 text=True,
304 timeout=30,
305 )
306 if result.returncode != 0: 306 ↛ 307line 306 didn't jump to line 307 because the condition on line 306 was never true
307 warn_tech(
308 code="translation.manager.lrelease_failed",
309 message=f"lrelease exited with code {result.returncode}: {result.stderr.strip()}",
310 )
311 except Exception as e:
312 warn_tech(
313 code="translation.manager.qm_compile_failed",
314 message=f"Failed to compile {ts_path.name} to .qm",
315 error=e,
316 )
318 return qm_path.exists()
320 # ------------------------------------------------------------------
322 def _load_ts_file(self, ts_file_path: Path) -> bool:
323 try:
324 import defusedxml.ElementTree as ET # type: ignore[import-untyped]
326 if not ts_file_path.exists(): 326 ↛ 327line 326 didn't jump to line 327 because the condition on line 326 was never true
327 return False
329 root = ET.parse(ts_file_path).getroot()
330 if root is None: 330 ↛ 331line 330 didn't jump to line 331 because the condition on line 330 was never true
331 return False
332 translations: dict[str, str] = {}
333 for message in root.findall(".//message"):
334 source = message.find("source")
335 translation = message.find("translation")
336 if ( 336 ↛ 333line 336 didn't jump to line 333 because the condition on line 336 was always true
337 source is not None
338 and translation is not None
339 and source.text
340 and translation.text
341 ):
342 translations[source.text] = translation.text
344 self._ts_translations.update(translations)
345 return True
346 except Exception as e:
347 warn_tech(
348 code="translation.manager.load_ts_failed",
349 message=f"Error loading .ts file {ts_file_path}",
350 error=e,
351 )
352 return False
354 def load_language(self, language_name: str) -> bool:
355 name_to_code = {
356 info["name"]: code for code, info in SUPPORTED_LANGUAGES.items()
357 }
358 if language_name in name_to_code:
359 return self.load_language_by_code(name_to_code[language_name])
360 return False
362 def load_language_by_code(self, language_code: str) -> bool:
363 if language_code not in SUPPORTED_LANGUAGES:
364 warn_user(
365 code="translation.manager.unsupported_language",
366 user_message=f"Unsupported language: {language_code}",
367 )
368 return False
370 self._ts_translations.clear()
372 app = QCoreApplication.instance()
373 if app is not None: 373 ↛ 383line 373 didn't jump to line 383 because the condition on line 373 was always true
374 try:
375 QCoreApplication.removeTranslator(self.translator)
376 except Exception as e:
377 warn_tech(
378 code="translation.manager.remove_translator_failed",
379 message="Error removing translator",
380 error=e,
381 )
383 self.translator = QTranslator()
384 language_info = SUPPORTED_LANGUAGES[language_code]
385 self.current_language = language_code
387 if self.translations_dir is None: 387 ↛ 388line 387 didn't jump to line 388 because the condition on line 387 was never true
388 warn_tech(
389 code="translation.manager.translations_dir_unavailable",
390 message=(
391 "Translations directory not resolved; .ts file load skipped "
392 f"for language '{language_code}'"
393 ),
394 )
395 self.languageChanged.emit(language_code)
396 return True
398 ts_file_path = self.translations_dir / language_info["file"]
400 if self._load_ts_file(ts_file_path): 400 ↛ 414line 400 didn't jump to line 414 because the condition on line 400 was always true
401 # Compile .ts → .qm (skipped when .qm is already up to date) and
402 # load into the QTranslator so QCoreApplication.translate() works.
403 qm_path = ts_file_path.with_suffix(".qm")
404 if self._ensure_qm_compiled( 404 ↛ 407line 404 didn't jump to line 407 because the condition on line 404 was never true
405 ts_file_path, qm_path
406 ) and not self.translator.load(str(qm_path)):
407 warn_tech(
408 code="translation.manager.load_qm_failed",
409 message=f"QTranslator.load() failed for {qm_path.name}",
410 )
411 get_printer().debug_msg(
412 f"[TranslationService] Language switched to {language_info['name']}"
413 )
414 elif language_code != DEFAULT_LANGUAGE:
415 # No .ts file yet for this language. Warn but continue: installing an
416 # empty translator still fires Qt's LanguageChange event, which causes
417 # all widgets to call retranslate_ui() → QCoreApplication.translate()
418 # → EzTranslator.translate() → auto-translation for every string.
419 warn_user(
420 code="translation.manager.load_language_failed",
421 user_message=(
422 f"No translation file found for {language_info['name']} — "
423 "auto-translation will populate it progressively."
424 ),
425 )
427 # Always install the translator (empty or .qm-backed) so Qt posts a
428 # LanguageChange event. Without this call, widgets never call
429 # retranslate_ui() and EzTranslator is never invoked for the new language.
430 if app is not None: 430 ↛ 440line 430 didn't jump to line 440 because the condition on line 430 was always true
431 try:
432 QCoreApplication.installTranslator(self.translator)
433 except Exception as e:
434 warn_tech(
435 code="translation.manager.install_translator_failed",
436 message="Error installing translator",
437 error=e,
438 )
440 self.languageChanged.emit(language_code)
441 return True
443 def get_available_languages(self) -> list[str]:
444 return list(SUPPORTED_LANGUAGES.keys())
446 def get_current_language_name(self) -> str:
447 if self.current_language in SUPPORTED_LANGUAGES: 447 ↛ 449line 447 didn't jump to line 449 because the condition on line 447 was always true
448 return SUPPORTED_LANGUAGES[self.current_language]["name"]
449 return "Unknown"
451 def get_current_language_code(self) -> str:
452 return self.current_language
454 @property
455 def translation_count(self) -> int:
456 """Return the number of cached translations."""
457 return len(self._ts_translations)
459 def translate(self, text: str) -> str:
460 if text in self._ts_translations: 460 ↛ 461line 460 didn't jump to line 461 because the condition on line 460 was never true
461 return self._ts_translations[text]
463 translated = self.translator.translate("", text)
464 if translated and translated != text: 464 ↛ 465line 464 didn't jump to line 465 because the condition on line 464 was never true
465 return translated
467 # Fire async auto-translation — widgets update via _on_auto_translation_ready
468 # when the result arrives. Never call translate_sync() from the UI thread.
469 if self.auto_translation_enabled and self.auto_translator.enabled: 469 ↛ 470line 469 didn't jump to line 470 because the condition on line 469 was never true
470 result = self.auto_translator.translate(text, "en", self.current_language)
471 # Increment the pending counter only when a real async request was
472 # dispatched (translate() returns None in that case). A non-None
473 # result means the cache was hit synchronously and no worker task
474 # will emit translation_ready later.
475 if result is None:
476 self._increment_pending()
478 return text
480 # ------------------------------------------------------------------
481 # Pending-count helpers
482 # ------------------------------------------------------------------
484 def _increment_pending(self) -> None:
485 """Increment the pending auto-translation counter.
487 Emits :attr:`translation_started` when the count transitions from 0
488 to 1, signalling that at least one async translation is in flight.
489 """
490 self._pending_auto_translations += 1
491 if self._pending_auto_translations == 1:
492 self.translation_started.emit()
494 def _decrement_pending(self) -> None:
495 """Decrement the pending auto-translation counter (floor: 0).
497 Emits :attr:`translation_finished` when the count reaches zero,
498 signalling that all in-flight translations have been resolved.
499 """
500 self._pending_auto_translations = max(0, self._pending_auto_translations - 1)
501 if self._pending_auto_translations == 0:
502 self.translation_finished.emit()
504 def _on_auto_translation_error(self, _original: str, _error: str) -> None:
505 """Slot called when an async auto-translation fails.
507 Decrements the pending counter so the UI indicator is hidden even on
508 failure. Error reporting is handled by :class:`AutoTranslator` itself.
510 Args:
511 _original: The source string that could not be translated (unused here).
512 _error: The error message reported by the provider (unused here).
513 """
514 self._decrement_pending()
516 def _on_auto_translation_ready(self, original: str, translated: str) -> None:
517 """Slot called when an async auto-translation completes.
519 Caches the result, optionally persists it to the .ts file (and
520 recompiles the .qm), then triggers a ``QEvent::LanguageChange`` on all
521 widgets by reinstalling ``_ez_translator``. This causes every widget's
522 ``changeEvent`` / ``retranslate_ui`` to run, at which point
523 ``QCoreApplication.translate("EzQt_App", original)`` finds the new
524 entry either in ``_ts_translations`` (via ``EzTranslator``) or in the
525 freshly reloaded ``.qm`` file.
527 Args:
528 original: The source (English) string that was translated.
529 translated: The translated string in the current language.
530 """
531 self._decrement_pending()
532 self._ts_translations[original] = translated
533 if self.auto_save_translations: 533 ↛ 537line 533 didn't jump to line 537 because the condition on line 533 was never true
534 # _save_auto_translation_to_ts reloads the .qm translator, which
535 # already calls installTranslator() and thus posts LanguageChange.
536 # No need to reinstall _ez_translator separately in that case.
537 self._save_auto_translation_to_ts(original, translated)
538 else:
539 # Without .ts persistence the .qm is not reloaded. Reinstall
540 # _ez_translator to post LanguageChange so widgets call
541 # retranslate_ui() and pick up the new entry from _ts_translations.
542 app = QCoreApplication.instance()
543 if app is not None: 543 ↛ exitline 543 didn't return from function '_on_auto_translation_ready' because the condition on line 543 was always true
544 try:
545 QCoreApplication.installTranslator(self._ez_translator)
546 except Exception as e:
547 warn_tech(
548 code="translation.manager.ez_translator_reinstall_failed",
549 message="Could not reinstall EzTranslator after auto-translation",
550 error=e,
551 )
553 def _persist_translation(self, original: str, translated: str) -> None:
554 """Persist an immediately-resolved translation to the current .ts file.
556 Called by :class:`EzTranslator` when ``auto_translator.translate()``
557 returns a result synchronously (identity translation or cache hit).
558 Unlike :meth:`_save_auto_translation_to_ts` this method does **not**
559 recompile the .qm or reinstall the translator — the text is already
560 correct in the UI, so no ``LanguageChange`` event is needed. The .qm
561 will be recompiled on the next :meth:`load_language_by_code` call (the
562 mtime check in :meth:`_ensure_qm_compiled` detects the stale .qm).
564 Args:
565 original: The source (English) string.
566 translated: The translation to persist (may equal *original* for the
567 source language — identity mapping).
568 """
569 if not self.auto_save_translations or self.translations_dir is None:
570 return
571 if self.current_language not in SUPPORTED_LANGUAGES:
572 return
573 language_info = SUPPORTED_LANGUAGES[self.current_language]
574 ts_file_path = self.translations_dir / language_info["file"]
575 self.auto_translator.save_translation_to_ts(
576 original, translated, self.current_language, ts_file_path
577 )
579 def _save_auto_translation_to_ts(self, original: str, translated: str) -> None:
580 """Persist *translated* to the active .ts file and reload the .qm translator.
582 After writing the new entry to the .ts file the method recompiles it to
583 a fresh .qm and reloads ``self.translator``. Subsequent calls to
584 ``QCoreApplication.translate("EzQt_App", original)`` will therefore
585 find the string in the .qm-backed translator (in addition to
586 ``EzTranslator``'s in-memory cache), making the translation durable
587 across application restarts.
589 Args:
590 original: The source (English) string.
591 translated: The translated string to persist.
592 """
593 try:
594 if self.current_language not in SUPPORTED_LANGUAGES:
595 return
596 if self.translations_dir is None:
597 return
599 language_info = SUPPORTED_LANGUAGES[self.current_language]
600 ts_file_path = self.translations_dir / language_info["file"]
601 self.auto_translator.save_translation_to_ts(
602 original, translated, self.current_language, ts_file_path
603 )
604 self._ts_translations[original] = translated
606 # Recompile .ts → .qm so the entry is available via the native
607 # QTranslator on the next language load (and on app restart).
608 qm_path = ts_file_path.with_suffix(".qm")
609 if self._ensure_qm_compiled(ts_file_path, qm_path):
610 app = QCoreApplication.instance()
611 if app is not None:
612 # Swap out the .qm translator atomically: remove the old
613 # one, load the updated binary, reinstall.
614 try:
615 QCoreApplication.removeTranslator(self.translator)
616 self.translator = QTranslator()
617 if not self.translator.load(str(qm_path)):
618 warn_tech(
619 code="translation.manager.reload_qm_failed",
620 message=(
621 f"QTranslator.load() failed after auto-save "
622 f"for {qm_path.name}"
623 ),
624 )
625 # Install first so it is consulted before EzTranslator
626 # (LIFO order: last installed = first consulted).
627 QCoreApplication.installTranslator(self.translator)
628 except Exception as e:
629 warn_tech(
630 code="translation.manager.reload_qm_install_failed",
631 message="Error reloading .qm translator after auto-save",
632 error=e,
633 )
634 except Exception as e:
635 warn_tech(
636 code="translation.manager.auto_translation_save_failed",
637 message="Error saving automatic translation",
638 error=e,
639 )
641 def enable_auto_translation(self, enabled: bool = True) -> None:
642 self.auto_translation_enabled = enabled
643 if self.auto_translator:
644 self.auto_translator.enabled = enabled
645 get_printer().action(
646 "[TranslationService] Automatic translation "
647 f"{'enabled' if enabled else 'disabled'}"
648 )
650 def get_auto_translation_stats(self) -> dict[str, Any]:
651 if self.auto_translator:
652 return self.auto_translator.get_cache_stats()
653 return {}
655 def clear_auto_translation_cache(self) -> None:
656 if self.auto_translator:
657 self.auto_translator.clear_cache()
660# ///////////////////////////////////////////////////////////////
661# FUNCTIONS
662# ///////////////////////////////////////////////////////////////
663def get_translation_manager() -> TranslationManager:
664 """Return the global TranslationManager singleton."""
665 from .._registry import ServiceRegistry
667 return ServiceRegistry.get(TranslationManager, TranslationManager)