Coverage for src / ezqt_app / services / translation / manager.py: 58.91%
256 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-26 07:07 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-26 07:07 +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( # type: ignore[override]
69 self,
70 context: str,
71 source_text: str,
72 _disambiguation: str | None = None,
73 _n: int = -1,
74 ) -> str | None:
75 """Return the translation for *source_text*, or ``None`` if unknown.
77 PySide6 maps a ``None`` return value to a null ``QString``, which tells
78 Qt to continue querying the next installed translator and ultimately to
79 fall back to the source text. Returning ``""`` (an empty but non-null
80 ``QString``) would be interpreted as "the translation is an empty
81 string", which is incorrect for strings not yet translated.
83 When the string is absent from the in-memory cache and auto-translation
84 is active, an async translation request is fired so the string will be
85 available on the next ``LanguageChange`` cycle.
87 Args:
88 context: Qt translation context (only ``"EzQt_App"`` is handled).
89 source_text: The English source string to translate.
90 _disambiguation: Optional disambiguation hint (unused).
91 _n: Plural form selector (unused).
93 Returns:
94 The translated string if already cached, ``None`` otherwise.
95 """
96 if context != self._CONTEXT or not source_text:
97 return None
99 cached = self._manager._ts_translations.get(source_text)
100 if cached:
101 return cached
103 # String is unknown — fire auto-translation if enabled.
104 # auto_translator.translate() handles all cases uniformly:
105 # • source == target → returns text immediately (identity, no HTTP)
106 # • cache hit → returns cached value immediately
107 # • cache miss → spawns a daemon thread, returns None
108 # All languages including English are processed so that every .ts file
109 # is populated with real entries and compiled to a valid .qm.
110 if ( 110 ↛ 114line 110 didn't jump to line 114 because the condition on line 110 was never true
111 self._manager.auto_translation_enabled
112 and self._manager.auto_translator.enabled
113 ):
114 result = self._manager.auto_translator.translate(
115 source_text, DEFAULT_LANGUAGE, self._manager.current_language
116 )
117 if result is not None:
118 # Immediate result (identity or cache hit): populate in-memory
119 # cache and persist to .ts so the string survives the next load.
120 self._manager._ts_translations[source_text] = result
121 self._manager._persist_translation(source_text, result)
122 return result
123 # Async request dispatched — a thread is running the HTTP round-trip.
124 self._manager._increment_pending()
126 # Return None so Qt falls back to the .qm translator or source text.
127 return None
130class TranslationManager(QObject):
131 """Core translation engine for EzQt_App.
133 Handles .ts file loading, language switching, Qt translator installation
134 and automatic widget retranslation on language change.
135 """
137 languageChanged = Signal(str)
138 # Emitted when the first pending auto-translation is enqueued (count > 0).
139 # Consumers (e.g. BottomBar) use this to show a progress indicator.
140 translation_started = Signal()
141 # Emitted when all pending auto-translations have been resolved (count == 0).
142 # Consumers use this to hide the progress indicator.
143 translation_finished = Signal()
145 def __init__(self) -> None:
146 super().__init__()
147 self.translator = QTranslator()
148 self.current_language = DEFAULT_LANGUAGE
150 self._ts_translations: dict[str, str] = {}
152 # Count of async auto-translation requests that have been fired but
153 # for which no result (ready or error) has arrived yet. Incremented
154 # each time a new request is dispatched; decremented on every outcome.
155 # When the count transitions 0 → 1 we emit translation_started; when
156 # it transitions 1 → 0 we emit translation_finished.
157 self._pending_auto_translations: int = 0
159 # EzTranslator intercepts QCoreApplication.translate("EzQt_App", text)
160 # calls for strings absent from the compiled .qm file (e.g. developer
161 # custom strings not yet added to the .ts). It is installed once and
162 # kept across language switches; only the .qm-backed translator is
163 # swapped on each language change.
164 self._ez_translator = EzTranslator(self)
166 self.auto_translator = get_auto_translator()
167 self.auto_translation_enabled = False
168 self.auto_save_translations = False
170 try:
171 translation_cfg = (
172 get_config_service().load_config("translation").get("translation", {})
173 )
174 if isinstance(translation_cfg, dict): 174 ↛ 188line 174 didn't jump to line 188 because the condition on line 174 was always true
175 self.auto_translation_enabled = _parse_bool(
176 translation_cfg.get("auto_translation_enabled", False)
177 )
178 self.auto_save_translations = _parse_bool(
179 translation_cfg.get("save_to_ts_files", False)
180 )
181 except Exception as e:
182 warn_tech(
183 code="translation.manager.config_load_failed",
184 message="Could not load translation config; using defaults (disabled)",
185 error=e,
186 )
188 self.auto_translator.enabled = self.auto_translation_enabled
189 self.auto_translator.translation_ready.connect(self._on_auto_translation_ready)
190 self.auto_translator.translation_error.connect(self._on_auto_translation_error)
192 # Register cleanup on application exit: stop the worker thread and purge
193 # the expired cache entries. Guard against QCoreApplication not yet existing
194 # (e.g., when the manager is instantiated in a test context without a Qt app).
195 # Also install EzTranslator so it begins intercepting QCoreApplication.translate()
196 # calls immediately — even before the first explicit language switch.
197 _qapp = QCoreApplication.instance()
198 if _qapp is not None: 198 ↛ 203line 198 didn't jump to line 203 because the condition on line 198 was always true
199 _qapp.aboutToQuit.connect(self.auto_translator.cleanup)
200 QCoreApplication.installTranslator(self._ez_translator)
202 # Resolve translations directory
203 if hasattr(sys, "_MEIPASS"): 203 ↛ 204line 203 didn't jump to line 204 because the condition on line 203 was never true
204 self.translations_dir: Path | None = (
205 Path(sys._MEIPASS) # pyright: ignore[reportAttributeAccessIssue]
206 / "ezqt_app"
207 / "resources"
208 / "translations"
209 )
210 else:
211 possible_paths = [
212 get_bin_path() / "translations",
213 Path(__file__).parent.parent.parent / "resources" / "translations",
214 ]
215 try:
216 pkg_dir = self._get_package_translations_dir()
217 if pkg_dir.exists(): 217 ↛ 226line 217 didn't jump to line 226 because the condition on line 217 was always true
218 possible_paths.append(pkg_dir)
219 except Exception as e:
220 warn_tech(
221 code="translation.manager.package_dir_resolution_failed",
222 message="Could not resolve package translations dir",
223 error=e,
224 )
226 self.translations_dir = next(
227 (p for p in possible_paths if p.exists()), None
228 )
229 if self.translations_dir is None: 229 ↛ 230line 229 didn't jump to line 230 because the condition on line 229 was never true
230 self.translations_dir = get_bin_path() / "translations"
231 self.translations_dir.mkdir(parents=True, exist_ok=True)
233 def _get_package_translations_dir(self) -> Path:
234 try:
235 import pkg_resources # type: ignore[import-untyped]
237 return Path(
238 pkg_resources.resource_filename("ezqt_app", "resources/translations")
239 )
240 except Exception:
241 return Path(__file__).parent.parent.parent / "resources" / "translations"
243 # ------------------------------------------------------------------
244 # .qm compilation helpers
245 # ------------------------------------------------------------------
247 def _find_lrelease(self) -> Path | None:
248 """Locate the pyside6-lrelease executable.
250 Searches PATH first (the ``pyside6-lrelease`` wrapper script), then
251 falls back to the ``lrelease[.exe]`` binary inside the PySide6 package
252 directory. Returns ``None`` if the tool cannot be found.
253 """
254 import shutil
256 which = shutil.which("pyside6-lrelease")
257 if which: 257 ↛ 260line 257 didn't jump to line 260 because the condition on line 257 was always true
258 return Path(which)
260 try:
261 import PySide6
263 pyside6_dir = Path(PySide6.__file__).parent
264 for name in ("lrelease.exe", "lrelease"):
265 candidate = pyside6_dir / name
266 if candidate.exists():
267 return candidate
268 except Exception as e:
269 warn_tech(
270 code="translation.manager.pyside6_lrelease_lookup_failed",
271 message="Could not locate lrelease in PySide6 package directory",
272 error=e,
273 )
275 return None
277 def _ensure_qm_compiled(self, ts_path: Path, qm_path: Path) -> bool:
278 """Compile *ts_path* → *qm_path* using ``pyside6-lrelease`` if needed.
280 Skips recompilation when *qm_path* is already up-to-date (mtime ≥
281 *ts_path* mtime). Returns ``True`` if *qm_path* exists after the
282 method completes (either freshly compiled or already current).
283 """
284 if qm_path.exists() and qm_path.stat().st_mtime >= ts_path.stat().st_mtime:
285 return True
287 lrelease = self._find_lrelease()
288 if lrelease is None: 288 ↛ 289line 288 didn't jump to line 289 because the condition on line 288 was never true
289 warn_tech(
290 code="translation.manager.lrelease_not_found",
291 message=(
292 "pyside6-lrelease not found; .qm file will not be generated. "
293 "Install PySide6 tools or ensure pyside6-lrelease is on PATH."
294 ),
295 )
296 return qm_path.exists()
298 try:
299 import subprocess
301 result = subprocess.run(
302 [str(lrelease), str(ts_path), "-qm", str(qm_path)],
303 capture_output=True,
304 text=True,
305 timeout=30,
306 )
307 if result.returncode != 0: 307 ↛ 308line 307 didn't jump to line 308 because the condition on line 307 was never true
308 warn_tech(
309 code="translation.manager.lrelease_failed",
310 message=f"lrelease exited with code {result.returncode}: {result.stderr.strip()}",
311 )
312 except Exception as e:
313 warn_tech(
314 code="translation.manager.qm_compile_failed",
315 message=f"Failed to compile {ts_path.name} to .qm",
316 error=e,
317 )
319 return qm_path.exists()
321 # ------------------------------------------------------------------
323 def _load_ts_file(self, ts_file_path: Path) -> bool:
324 try:
325 import xml.etree.ElementTree as ET
327 if not ts_file_path.exists(): 327 ↛ 328line 327 didn't jump to line 328 because the condition on line 327 was never true
328 return False
330 root = ET.parse(ts_file_path).getroot() # noqa: S314
331 translations: dict[str, str] = {}
332 for message in root.findall(".//message"):
333 source = message.find("source")
334 translation = message.find("translation")
335 if ( 335 ↛ 332line 335 didn't jump to line 332 because the condition on line 335 was always true
336 source is not None
337 and translation is not None
338 and source.text
339 and translation.text
340 ):
341 translations[source.text] = translation.text
343 self._ts_translations.update(translations)
344 return True
345 except Exception as e:
346 warn_tech(
347 code="translation.manager.load_ts_failed",
348 message=f"Error loading .ts file {ts_file_path}",
349 error=e,
350 )
351 return False
353 def load_language(self, language_name: str) -> bool:
354 name_to_code = {
355 info["name"]: code for code, info in SUPPORTED_LANGUAGES.items()
356 }
357 if language_name in name_to_code:
358 return self.load_language_by_code(name_to_code[language_name])
359 return False
361 def load_language_by_code(self, language_code: str) -> bool:
362 if language_code not in SUPPORTED_LANGUAGES:
363 warn_user(
364 code="translation.manager.unsupported_language",
365 user_message=f"Unsupported language: {language_code}",
366 )
367 return False
369 self._ts_translations.clear()
371 app = QCoreApplication.instance()
372 if app is not None: 372 ↛ 382line 372 didn't jump to line 382 because the condition on line 372 was always true
373 try:
374 QCoreApplication.removeTranslator(self.translator)
375 except Exception as e:
376 warn_tech(
377 code="translation.manager.remove_translator_failed",
378 message="Error removing translator",
379 error=e,
380 )
382 self.translator = QTranslator()
383 language_info = SUPPORTED_LANGUAGES[language_code]
384 self.current_language = language_code
386 if self.translations_dir is None: 386 ↛ 387line 386 didn't jump to line 387 because the condition on line 386 was never true
387 warn_tech(
388 code="translation.manager.translations_dir_unavailable",
389 message=(
390 "Translations directory not resolved; .ts file load skipped "
391 f"for language '{language_code}'"
392 ),
393 )
394 self.languageChanged.emit(language_code)
395 return True
397 ts_file_path = self.translations_dir / language_info["file"]
399 if self._load_ts_file(ts_file_path): 399 ↛ 413line 399 didn't jump to line 413 because the condition on line 399 was always true
400 # Compile .ts → .qm (skipped when .qm is already up to date) and
401 # load into the QTranslator so QCoreApplication.translate() works.
402 qm_path = ts_file_path.with_suffix(".qm")
403 if self._ensure_qm_compiled( 403 ↛ 406line 403 didn't jump to line 406 because the condition on line 403 was never true
404 ts_file_path, qm_path
405 ) and not self.translator.load(str(qm_path)):
406 warn_tech(
407 code="translation.manager.load_qm_failed",
408 message=f"QTranslator.load() failed for {qm_path.name}",
409 )
410 get_printer().debug_msg(
411 f"[TranslationService] Language switched to {language_info['name']}"
412 )
413 elif language_code != DEFAULT_LANGUAGE:
414 # No .ts file yet for this language. Warn but continue: installing an
415 # empty translator still fires Qt's LanguageChange event, which causes
416 # all widgets to call retranslate_ui() → QCoreApplication.translate()
417 # → EzTranslator.translate() → auto-translation for every string.
418 warn_user(
419 code="translation.manager.load_language_failed",
420 user_message=(
421 f"No translation file found for {language_info['name']} — "
422 "auto-translation will populate it progressively."
423 ),
424 )
426 # Always install the translator (empty or .qm-backed) so Qt posts a
427 # LanguageChange event. Without this call, widgets never call
428 # retranslate_ui() and EzTranslator is never invoked for the new language.
429 if app is not None: 429 ↛ 439line 429 didn't jump to line 439 because the condition on line 429 was always true
430 try:
431 QCoreApplication.installTranslator(self.translator)
432 except Exception as e:
433 warn_tech(
434 code="translation.manager.install_translator_failed",
435 message="Error installing translator",
436 error=e,
437 )
439 self.languageChanged.emit(language_code)
440 return True
442 def get_available_languages(self) -> list[str]:
443 return list(SUPPORTED_LANGUAGES.keys())
445 def get_current_language_name(self) -> str:
446 if self.current_language in SUPPORTED_LANGUAGES: 446 ↛ 448line 446 didn't jump to line 448 because the condition on line 446 was always true
447 return SUPPORTED_LANGUAGES[self.current_language]["name"]
448 return "Unknown"
450 def get_current_language_code(self) -> str:
451 return self.current_language
453 @property
454 def translation_count(self) -> int:
455 """Return the number of cached translations."""
456 return len(self._ts_translations)
458 def translate(self, text: str) -> str:
459 if text in self._ts_translations: 459 ↛ 460line 459 didn't jump to line 460 because the condition on line 459 was never true
460 return self._ts_translations[text]
462 translated = self.translator.translate("", text)
463 if translated and translated != text: 463 ↛ 464line 463 didn't jump to line 464 because the condition on line 463 was never true
464 return translated
466 # Fire async auto-translation — widgets update via _on_auto_translation_ready
467 # when the result arrives. Never call translate_sync() from the UI thread.
468 if self.auto_translation_enabled and self.auto_translator.enabled: 468 ↛ 469line 468 didn't jump to line 469 because the condition on line 468 was never true
469 result = self.auto_translator.translate(text, "en", self.current_language)
470 # Increment the pending counter only when a real async request was
471 # dispatched (translate() returns None in that case). A non-None
472 # result means the cache was hit synchronously and no worker task
473 # will emit translation_ready later.
474 if result is None:
475 self._increment_pending()
477 return text
479 # ------------------------------------------------------------------
480 # Pending-count helpers
481 # ------------------------------------------------------------------
483 def _increment_pending(self) -> None:
484 """Increment the pending auto-translation counter.
486 Emits :attr:`translation_started` when the count transitions from 0
487 to 1, signalling that at least one async translation is in flight.
488 """
489 self._pending_auto_translations += 1
490 if self._pending_auto_translations == 1:
491 self.translation_started.emit()
493 def _decrement_pending(self) -> None:
494 """Decrement the pending auto-translation counter (floor: 0).
496 Emits :attr:`translation_finished` when the count reaches zero,
497 signalling that all in-flight translations have been resolved.
498 """
499 self._pending_auto_translations = max(0, self._pending_auto_translations - 1)
500 if self._pending_auto_translations == 0:
501 self.translation_finished.emit()
503 def _on_auto_translation_error(self, _original: str, _error: str) -> None:
504 """Slot called when an async auto-translation fails.
506 Decrements the pending counter so the UI indicator is hidden even on
507 failure. Error reporting is handled by :class:`AutoTranslator` itself.
509 Args:
510 _original: The source string that could not be translated (unused here).
511 _error: The error message reported by the provider (unused here).
512 """
513 self._decrement_pending()
515 def _on_auto_translation_ready(self, original: str, translated: str) -> None:
516 """Slot called when an async auto-translation completes.
518 Caches the result, optionally persists it to the .ts file (and
519 recompiles the .qm), then triggers a ``QEvent::LanguageChange`` on all
520 widgets by reinstalling ``_ez_translator``. This causes every widget's
521 ``changeEvent`` / ``retranslate_ui`` to run, at which point
522 ``QCoreApplication.translate("EzQt_App", original)`` finds the new
523 entry either in ``_ts_translations`` (via ``EzTranslator``) or in the
524 freshly reloaded ``.qm`` file.
526 Args:
527 original: The source (English) string that was translated.
528 translated: The translated string in the current language.
529 """
530 self._decrement_pending()
531 self._ts_translations[original] = translated
532 if self.auto_save_translations: 532 ↛ 536line 532 didn't jump to line 536 because the condition on line 532 was never true
533 # _save_auto_translation_to_ts reloads the .qm translator, which
534 # already calls installTranslator() and thus posts LanguageChange.
535 # No need to reinstall _ez_translator separately in that case.
536 self._save_auto_translation_to_ts(original, translated)
537 else:
538 # Without .ts persistence the .qm is not reloaded. Reinstall
539 # _ez_translator to post LanguageChange so widgets call
540 # retranslate_ui() and pick up the new entry from _ts_translations.
541 app = QCoreApplication.instance()
542 if app is not None: 542 ↛ exitline 542 didn't return from function '_on_auto_translation_ready' because the condition on line 542 was always true
543 try:
544 QCoreApplication.installTranslator(self._ez_translator)
545 except Exception as e:
546 warn_tech(
547 code="translation.manager.ez_translator_reinstall_failed",
548 message="Could not reinstall EzTranslator after auto-translation",
549 error=e,
550 )
552 def _persist_translation(self, original: str, translated: str) -> None:
553 """Persist an immediately-resolved translation to the current .ts file.
555 Called by :class:`EzTranslator` when ``auto_translator.translate()``
556 returns a result synchronously (identity translation or cache hit).
557 Unlike :meth:`_save_auto_translation_to_ts` this method does **not**
558 recompile the .qm or reinstall the translator — the text is already
559 correct in the UI, so no ``LanguageChange`` event is needed. The .qm
560 will be recompiled on the next :meth:`load_language_by_code` call (the
561 mtime check in :meth:`_ensure_qm_compiled` detects the stale .qm).
563 Args:
564 original: The source (English) string.
565 translated: The translation to persist (may equal *original* for the
566 source language — identity mapping).
567 """
568 if not self.auto_save_translations or self.translations_dir is None:
569 return
570 if self.current_language not in SUPPORTED_LANGUAGES:
571 return
572 language_info = SUPPORTED_LANGUAGES[self.current_language]
573 ts_file_path = self.translations_dir / language_info["file"]
574 self.auto_translator.save_translation_to_ts(
575 original, translated, self.current_language, ts_file_path
576 )
578 def _save_auto_translation_to_ts(self, original: str, translated: str) -> None:
579 """Persist *translated* to the active .ts file and reload the .qm translator.
581 After writing the new entry to the .ts file the method recompiles it to
582 a fresh .qm and reloads ``self.translator``. Subsequent calls to
583 ``QCoreApplication.translate("EzQt_App", original)`` will therefore
584 find the string in the .qm-backed translator (in addition to
585 ``EzTranslator``'s in-memory cache), making the translation durable
586 across application restarts.
588 Args:
589 original: The source (English) string.
590 translated: The translated string to persist.
591 """
592 try:
593 if self.current_language not in SUPPORTED_LANGUAGES:
594 return
595 if self.translations_dir is None:
596 return
598 language_info = SUPPORTED_LANGUAGES[self.current_language]
599 ts_file_path = self.translations_dir / language_info["file"]
600 self.auto_translator.save_translation_to_ts(
601 original, translated, self.current_language, ts_file_path
602 )
603 self._ts_translations[original] = translated
605 # Recompile .ts → .qm so the entry is available via the native
606 # QTranslator on the next language load (and on app restart).
607 qm_path = ts_file_path.with_suffix(".qm")
608 if self._ensure_qm_compiled(ts_file_path, qm_path):
609 app = QCoreApplication.instance()
610 if app is not None:
611 # Swap out the .qm translator atomically: remove the old
612 # one, load the updated binary, reinstall.
613 try:
614 QCoreApplication.removeTranslator(self.translator)
615 self.translator = QTranslator()
616 if not self.translator.load(str(qm_path)):
617 warn_tech(
618 code="translation.manager.reload_qm_failed",
619 message=(
620 f"QTranslator.load() failed after auto-save "
621 f"for {qm_path.name}"
622 ),
623 )
624 # Install first so it is consulted before EzTranslator
625 # (LIFO order: last installed = first consulted).
626 QCoreApplication.installTranslator(self.translator)
627 except Exception as e:
628 warn_tech(
629 code="translation.manager.reload_qm_install_failed",
630 message="Error reloading .qm translator after auto-save",
631 error=e,
632 )
633 except Exception as e:
634 warn_tech(
635 code="translation.manager.auto_translation_save_failed",
636 message="Error saving automatic translation",
637 error=e,
638 )
640 def enable_auto_translation(self, enabled: bool = True) -> None:
641 self.auto_translation_enabled = enabled
642 if self.auto_translator:
643 self.auto_translator.enabled = enabled
644 get_printer().action(
645 "[TranslationService] Automatic translation "
646 f"{'enabled' if enabled else 'disabled'}"
647 )
649 def get_auto_translation_stats(self) -> dict[str, Any]:
650 if self.auto_translator:
651 return self.auto_translator.get_cache_stats()
652 return {}
654 def clear_auto_translation_cache(self) -> None:
655 if self.auto_translator:
656 self.auto_translator.clear_cache()
659# ///////////////////////////////////////////////////////////////
660# FUNCTIONS
661# ///////////////////////////////////////////////////////////////
662def get_translation_manager() -> TranslationManager:
663 """Return the global TranslationManager singleton."""
664 from .._registry import ServiceRegistry
666 return ServiceRegistry.get(TranslationManager, TranslationManager)