Translation and Localization
Translation stack: service adapter, manager, auto-translation providers, and string collection.
TranslationService
TranslationService
Bases: TranslationServiceProtocol
Service wrapper around translation management.
change_language
change_language(language_name: str) -> bool
Switch application language using its display name.
Source code in src/ezqt_app/services/translation/translation_service.py
| def change_language(self, language_name: str) -> bool:
"""Switch application language using its display name."""
return get_translation_manager().load_language(language_name)
|
change_language_by_code
change_language_by_code(language_code: str) -> bool
Switch application language using ISO code (e.g. "fr").
Source code in src/ezqt_app/services/translation/translation_service.py
| def change_language_by_code(self, language_code: str) -> bool:
"""Switch application language using ISO code (e.g. ``"fr"``)."""
return get_translation_manager().load_language_by_code(language_code)
|
get_available_languages
get_available_languages() -> list[str]
Return available language codes.
Source code in src/ezqt_app/services/translation/translation_service.py
| def get_available_languages(self) -> list[str]:
"""Return available language codes."""
return get_translation_manager().get_available_languages()
|
get_current_language_name
get_current_language_name() -> str
Return the current language display name.
Source code in src/ezqt_app/services/translation/translation_service.py
| def get_current_language_name(self) -> str:
"""Return the current language display name."""
return get_translation_manager().get_current_language_name()
|
get_current_language_code
get_current_language_code() -> str
Return the current language code.
Source code in src/ezqt_app/services/translation/translation_service.py
| def get_current_language_code(self) -> str:
"""Return the current language code."""
return get_translation_manager().get_current_language_code()
|
translate
translate(text: str) -> str
Translate a text using the active language context.
Source code in src/ezqt_app/services/translation/translation_service.py
| def translate(self, text: str) -> str:
"""Translate a text using the active language context."""
return get_translation_manager().translate(text)
|
TranslationManager
TranslationManager
Bases: QObject
Core translation engine for EzQt_App.
Handles .ts file loading, language switching, Qt translator installation
and automatic widget retranslation on language change.
Source code in src/ezqt_app/services/translation/manager.py
| def __init__(self) -> None:
super().__init__()
self.translator = QTranslator()
self.current_language = DEFAULT_LANGUAGE
self._ts_translations: dict[str, str] = {}
# Count of async auto-translation requests that have been fired but
# for which no result (ready or error) has arrived yet. Incremented
# each time a new request is dispatched; decremented on every outcome.
# When the count transitions 0 → 1 we emit translation_started; when
# it transitions 1 → 0 we emit translation_finished.
self._pending_auto_translations: int = 0
# EzTranslator intercepts QCoreApplication.translate("EzQt_App", text)
# calls for strings absent from the compiled .qm file (e.g. developer
# custom strings not yet added to the .ts). It is installed once and
# kept across language switches; only the .qm-backed translator is
# swapped on each language change.
self._ez_translator = EzTranslator(self)
self.auto_translator = get_auto_translator()
self.auto_translation_enabled = False
self.auto_save_translations = False
try:
translation_cfg = (
get_config_service().load_config("translation").get("translation", {})
)
if isinstance(translation_cfg, dict):
self.auto_translation_enabled = _parse_bool(
translation_cfg.get("auto_translation_enabled", False)
)
self.auto_save_translations = _parse_bool(
translation_cfg.get("save_to_ts_files", False)
)
except Exception as e:
warn_tech(
code="translation.manager.config_load_failed",
message="Could not load translation config; using defaults (disabled)",
error=e,
)
self.auto_translator.enabled = self.auto_translation_enabled
self.auto_translator.translation_ready.connect(self._on_auto_translation_ready)
self.auto_translator.translation_error.connect(self._on_auto_translation_error)
# Register cleanup on application exit: stop the worker thread and purge
# the expired cache entries. Guard against QCoreApplication not yet existing
# (e.g., when the manager is instantiated in a test context without a Qt app).
# Also install EzTranslator so it begins intercepting QCoreApplication.translate()
# calls immediately — even before the first explicit language switch.
_qapp = QCoreApplication.instance()
if _qapp is not None:
_qapp.aboutToQuit.connect(self.auto_translator.cleanup)
QCoreApplication.installTranslator(self._ez_translator)
# Resolve translations directory
if hasattr(sys, "_MEIPASS"):
self.translations_dir: Path | None = (
Path(sys._MEIPASS) # pyright: ignore[reportAttributeAccessIssue]
/ "ezqt_app"
/ "resources"
/ "translations"
)
else:
possible_paths = [
get_bin_path() / "translations",
Path(__file__).parent.parent.parent / "resources" / "translations",
]
try:
pkg_dir = self._get_package_translations_dir()
if pkg_dir.exists():
possible_paths.append(pkg_dir)
except Exception as e:
warn_tech(
code="translation.manager.package_dir_resolution_failed",
message="Could not resolve package translations dir",
error=e,
)
self.translations_dir = next(
(p for p in possible_paths if p.exists()), None
)
if self.translations_dir is None:
self.translations_dir = get_bin_path() / "translations"
self.translations_dir.mkdir(parents=True, exist_ok=True)
|
translation_count
property
Return the number of cached translations.
EzTranslator
EzTranslator is a QTranslator subclass installed permanently into Qt's translator chain. It intercepts every QCoreApplication.translate("EzQt_App", text) call made by any widget:
- Cache hit — returns the translation immediately from the in-memory
_ts_translations dictionary.
- Identity — when the current language equals the source language (
en by default), returns source_text as-is and persists the identity mapping to the source .ts file.
- Cache miss — dispatches an async translation request (daemon thread) via
AutoTranslator and returns None so Qt falls back to the source text for the current render cycle. When the translation arrives, LanguageChange is re-emitted and widgets retranslate automatically.
EzTranslator is installed once at TranslationManager init and is never removed, even during language switches (only the .qm-backed translator is swapped).
EzTranslator
Bases: QTranslator
Qt translator that intercepts unknown strings for auto-translation.
This translator is installed alongside the compiled .qm translator.
Qt calls translate() on every installed translator in LIFO order until
one returns a non-empty string. When source_text is not yet known,
EzTranslator fires the async auto-translation pipeline and returns an
empty string so Qt falls back to the source text for this render cycle.
On the next LanguageChange (triggered once the translation arrives) the
string is found in _ts_translations and returned immediately.
| PARAMETER |
DESCRIPTION |
manager
|
The owning :class:TranslationManager instance.
TYPE:
TranslationManager
|
Source code in src/ezqt_app/services/translation/manager.py
| def __init__(self, manager: TranslationManager) -> None:
super().__init__()
self._manager = manager
|
translate
translate(context: str, source_text: str, _disambiguation: str | None = None, _n: int = -1) -> str | None
Return the translation for source_text, or None if unknown.
PySide6 maps a None return value to a null QString, which tells
Qt to continue querying the next installed translator and ultimately to
fall back to the source text. Returning "" (an empty but non-null
QString) would be interpreted as "the translation is an empty
string", which is incorrect for strings not yet translated.
When the string is absent from the in-memory cache and auto-translation
is active, an async translation request is fired so the string will be
available on the next LanguageChange cycle.
| PARAMETER |
DESCRIPTION |
context
|
Qt translation context (only "EzQt_App" is handled).
TYPE:
str
|
source_text
|
The English source string to translate.
TYPE:
str
|
_disambiguation
|
Optional disambiguation hint (unused).
TYPE:
str | None
DEFAULT:
None
|
_n
|
Plural form selector (unused).
TYPE:
int
DEFAULT:
-1
|
| RETURNS |
DESCRIPTION |
str | None
|
The translated string if already cached, None otherwise.
|
Source code in src/ezqt_app/services/translation/manager.py
| def translate( # type: ignore[override]
self,
context: str,
source_text: str,
_disambiguation: str | None = None,
_n: int = -1,
) -> str | None:
"""Return the translation for *source_text*, or ``None`` if unknown.
PySide6 maps a ``None`` return value to a null ``QString``, which tells
Qt to continue querying the next installed translator and ultimately to
fall back to the source text. Returning ``""`` (an empty but non-null
``QString``) would be interpreted as "the translation is an empty
string", which is incorrect for strings not yet translated.
When the string is absent from the in-memory cache and auto-translation
is active, an async translation request is fired so the string will be
available on the next ``LanguageChange`` cycle.
Args:
context: Qt translation context (only ``"EzQt_App"`` is handled).
source_text: The English source string to translate.
_disambiguation: Optional disambiguation hint (unused).
_n: Plural form selector (unused).
Returns:
The translated string if already cached, ``None`` otherwise.
"""
if context != self._CONTEXT or not source_text:
return None
cached = self._manager._ts_translations.get(source_text)
if cached:
return cached
# String is unknown — fire auto-translation if enabled.
# auto_translator.translate() handles all cases uniformly:
# • source == target → returns text immediately (identity, no HTTP)
# • cache hit → returns cached value immediately
# • cache miss → spawns a daemon thread, returns None
# All languages including English are processed so that every .ts file
# is populated with real entries and compiled to a valid .qm.
if (
self._manager.auto_translation_enabled
and self._manager.auto_translator.enabled
):
result = self._manager.auto_translator.translate(
source_text, DEFAULT_LANGUAGE, self._manager.current_language
)
if result is not None:
# Immediate result (identity or cache hit): populate in-memory
# cache and persist to .ts so the string survives the next load.
self._manager._ts_translations[source_text] = result
self._manager._persist_translation(source_text, result)
return result
# Async request dispatched — a thread is running the HTTP round-trip.
self._manager._increment_pending()
# Return None so Qt falls back to the .qm translator or source text.
return None
|
AutoTranslator
AutoTranslator
AutoTranslator(cache_dir: Path | None = None)
Bases: QObject
Automatic translation manager (disabled by default).
Each call to :meth:translate spawns a lightweight daemon thread that
performs the HTTP round-trip in the background. Signals are emitted from
that thread; Qt automatically delivers them via a queued connection to
slots that live in the main thread, so the UI is never blocked.
A _pending set (guarded by _lock) deduplicates in-flight requests:
if the same source string is requested while a thread for it is still
running, no second thread is spawned.
Source code in src/ezqt_app/services/translation/auto_translator.py
| def __init__(self, cache_dir: Path | None = None):
super().__init__()
if cache_dir is None:
cache_dir = Path.home() / ".ezqt" / "cache"
cache_dir.mkdir(parents=True, exist_ok=True)
self.cache = TranslationCache(cache_dir / "translations.json")
self.providers: list[TranslationProvider] = []
self._pending: set[str] = set()
self._lock = threading.Lock()
self._setup_providers()
self.enabled = False
|
translate
translate(text: str, source_lang: str, target_lang: str) -> str | None
Schedule an async translation and return None immediately.
If source_lang equals target_lang the method returns text
immediately (identity translation — no HTTP request is made).
If text is already cached the cached value is returned immediately.
Otherwise a daemon thread is started and None is returned; the
caller receives the result via :attr:translation_ready.
Source code in src/ezqt_app/services/translation/auto_translator.py
| def translate(self, text: str, source_lang: str, target_lang: str) -> str | None:
"""Schedule an async translation and return ``None`` immediately.
If *source_lang* equals *target_lang* the method returns *text*
immediately (identity translation — no HTTP request is made).
If *text* is already cached the cached value is returned immediately.
Otherwise a daemon thread is started and ``None`` is returned; the
caller receives the result via :attr:`translation_ready`.
"""
if source_lang == target_lang:
return text
cached = self.cache.get(text, source_lang, target_lang)
if cached:
return cached
with self._lock:
if text in self._pending:
return None
self._pending.add(text)
t = threading.Thread(
target=self._do_translate,
args=(text, source_lang, target_lang),
daemon=True,
name=f"ez-translate:{text[:30]}",
)
t.start()
return None
|
translate_sync
translate_sync(text: str, source_lang: str, target_lang: str) -> str | None
Translate text synchronously, blocking until a result is obtained.
Intended for use in CLI scripts, test helpers, and offline batch-processing
tools that run outside the Qt event loop. Each provider call is a blocking
HTTP request; the total wait time can reach len(providers) × timeout
seconds if all providers fail.
Warning
Never call this method from the Qt main (UI) thread. Doing so
blocks the event loop for the entire duration of the HTTP round-trips,
freezing the application UI. For in-app translation use
:meth:translate instead, which runs the request in a daemon thread.
Example::
# Appropriate usage — called from a CLI script, not from a Qt slot:
translator = get_auto_translator()
translator.enabled = True
result = translator.translate_sync("Hello", "en", "fr")
print(result) # "Bonjour"
| PARAMETER |
DESCRIPTION |
text
|
The source text to translate.
TYPE:
str
|
source_lang
|
BCP-47 language code of the source text (e.g. "en").
TYPE:
str
|
target_lang
|
BCP-47 language code of the desired output (e.g. "fr").
TYPE:
str
|
| RETURNS |
DESCRIPTION |
str | None
|
The translated string, or None if the translator is disabled or
|
str | None
|
|
Source code in src/ezqt_app/services/translation/auto_translator.py
| def translate_sync(
self, text: str, source_lang: str, target_lang: str
) -> str | None:
"""Translate text synchronously, blocking until a result is obtained.
Intended for use in CLI scripts, test helpers, and offline batch-processing
tools that run outside the Qt event loop. Each provider call is a blocking
HTTP request; the total wait time can reach ``len(providers) × timeout``
seconds if all providers fail.
Warning:
**Never call this method from the Qt main (UI) thread.** Doing so
blocks the event loop for the entire duration of the HTTP round-trips,
freezing the application UI. For in-app translation use
:meth:`translate` instead, which runs the request in a daemon thread.
Example::
# Appropriate usage — called from a CLI script, not from a Qt slot:
translator = get_auto_translator()
translator.enabled = True
result = translator.translate_sync("Hello", "en", "fr")
print(result) # "Bonjour"
Args:
text: The source text to translate.
source_lang: BCP-47 language code of the source text (e.g. ``"en"``).
target_lang: BCP-47 language code of the desired output (e.g. ``"fr"``).
Returns:
The translated string, or ``None`` if the translator is disabled or
all providers fail.
"""
if not self.enabled:
return None
cached = self.cache.get(text, source_lang, target_lang)
if cached:
return cached
for provider in self.providers:
try:
translation = provider.translate(text, source_lang, target_lang)
if translation:
self.cache.set(
text, source_lang, target_lang, translation, provider.name
)
return translation
time.sleep(provider.rate_limit_delay)
except Exception as e:
warn_tech(
code="translation.sync.provider_failed",
message=f"Translation error with {provider.name}",
error=e,
)
return None
|
save_translation_to_ts
save_translation_to_ts(original: str, translated: str, target_lang: str, ts_file_path: Path) -> None
Append a single translation entry to a Qt Linguist .ts XML file.
Source code in src/ezqt_app/services/translation/auto_translator.py
| def save_translation_to_ts(
self, original: str, translated: str, target_lang: str, ts_file_path: Path
) -> None:
"""Append a single translation entry to a Qt Linguist .ts XML file."""
import xml.etree.ElementTree as ET
try:
if ts_file_path.exists():
try:
tree = ET.parse(ts_file_path) # noqa: S314
root = tree.getroot()
except ET.ParseError:
root = ET.Element("TS", {"language": target_lang, "version": "2.1"})
tree = ET.ElementTree(root)
else:
root = ET.Element("TS", {"language": target_lang, "version": "2.1"})
tree = ET.ElementTree(root)
context = root.find("context")
if context is None:
context = ET.SubElement(root, "context")
ET.SubElement(context, "name").text = "ezqt_app"
# Update existing entry if source already present, otherwise append.
for msg in context.findall("message"):
src = msg.find("source")
if src is not None and src.text == original:
trans = msg.find("translation")
if trans is not None:
trans.text = translated
break
else:
msg = ET.SubElement(context, "message")
ET.SubElement(msg, "source").text = original
ET.SubElement(msg, "translation").text = translated
ts_file_path.parent.mkdir(parents=True, exist_ok=True)
tree.write(ts_file_path, encoding="unicode", xml_declaration=True)
get_printer().debug_msg(
f"[TranslationService] Translation saved to {ts_file_path}"
)
except Exception as e:
warn_tech(
code="translation.ts.save_failed",
message="Error saving translation to .ts file",
error=e,
)
|
StringCollector
StringCollector
StringCollector(user_dir: Path | None = None)
String collector with language detection and task generation.
Source code in src/ezqt_app/services/translation/string_collector.py
| def __init__(self, user_dir: Path | None = None):
if user_dir is None:
user_dir = Path.home() / ".ezqt"
self.user_dir = user_dir
self.user_dir.mkdir(parents=True, exist_ok=True)
self.translations_dir = self.user_dir / "translations"
self.cache_dir = self.user_dir / "cache"
self.translations_dir.mkdir(exist_ok=True)
self.cache_dir.mkdir(exist_ok=True)
self.pending_file = self.translations_dir / "pending_strings.txt"
self.processed_file = self.translations_dir / "processed_strings.txt"
self.language_detected_file = self.translations_dir / "language_detected.txt"
self.translation_tasks_file = self.translations_dir / "translation_tasks.json"
self._collected_strings: set[str] = set()
self._new_strings: set[str] = set()
self._language_detected_strings: list[tuple[str, str]] = []
|
Providers
GoogleTranslateProvider
MyMemoryProvider
LibreTranslateProvider
auto_translation_enabled in translation.config.yaml controls external provider calls.
save_to_ts_files: true enables runtime persistence: every translated string is appended to the active .ts file and the .qm is recompiled via pyside6-lrelease.
Widget registration and string collection are local workflow features and can remain enabled independently.