Skip to content

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

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

translation_count: int

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

EzTranslator(manager: TranslationManager)

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

all providers fail.

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.