From 81bd77eac2e9884c72a6a4df44737acdedac64ef Mon Sep 17 00:00:00 2001 From: Pierre Raybaut <1311787+PierreRaybaut@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:05:58 +0100 Subject: [PATCH] fix(configtools): fall back to English when a .mo catalog is corrupted gettext.translation(..., fallback=True) only handles missing catalogs: a corrupted or unreadable .mo raised OSError/struct.error/ValueError/ LookupError at import time and prevented guidata-based applications (PlotPy, Sigima, DataLab) from starting. get_translation now emits a RuntimeWarning identifying the faulty catalog and returns the untranslated (English) callback. Fixes PlotPyStack/guidata#119 Assisted-by: Claude Opus 5.5 --- doc/release_notes/release_3.16.md | 4 + guidata/configtools.py | 22 ++++- guidata/tests/unit/test_translations.py | 103 +++++++++++++++++++++++- 3 files changed, 127 insertions(+), 2 deletions(-) diff --git a/doc/release_notes/release_3.16.md b/doc/release_notes/release_3.16.md index 80da0de6..6b37c019 100644 --- a/doc/release_notes/release_3.16.md +++ b/doc/release_notes/release_3.16.md @@ -4,6 +4,10 @@ * Python 3.10 or later is now required. Python 3.9 users must keep an earlier compatible release. +🛠 Bug fixes: + +* **Application startup blocked by a corrupted translation file** — when a `.mo` translation catalog was corrupted or unreadable (e.g. truncated during installation), importing guidata failed with an error such as `OSError: Bad magic number`. The same applied to any application relying on `guidata.configtools.get_translation`, like PlotPy, Sigima or DataLab, so the application could not start at all. The interface now falls back to English, and a `RuntimeWarning` identifies the faulty catalog (fixes [Issue #119](https://github.com/PlotPyStack/guidata/issues/119)). + ## guidata Version 3.16.0 ## ✨ New features: diff --git a/guidata/configtools.py b/guidata/configtools.py index 051a51e0..707057a5 100644 --- a/guidata/configtools.py +++ b/guidata/configtools.py @@ -39,6 +39,7 @@ import gettext import os import os.path as osp +import struct import sys import warnings from collections.abc import Callable @@ -83,6 +84,13 @@ def get_module_data_path(modname: str, relpath: str | None = None) -> str: def get_translation(modname: str, dirname: str | None = None) -> Callable[[str], str]: """Return translation callback for module *modname* + If no translation catalog is available for the current language, the + returned callback leaves strings untranslated (English). If a catalog exists + but is corrupted or unreadable, the callback also leaves strings + untranslated, and a :class:`RuntimeWarning` is emitted instead of raising + an exception: a translation problem never prevents the application from + starting. + Args: modname (str): module name dirname (str): module directory @@ -98,7 +106,19 @@ def get_translation(modname: str, dirname: str | None = None) -> Callable[[str], if lang is not None: os.environ["LANG"] = lang modlocpath = get_module_locale_path(dirname) - return gettext.translation(modname, modlocpath, fallback=True).gettext + try: + translation = gettext.translation(modname, modlocpath, fallback=True) + except (OSError, struct.error, ValueError, LookupError) as exc: + # `fallback=True` only covers missing catalogs: a corrupted or unreadable + # catalog must not prevent the application from starting + warnings.warn( + f"Unable to load {modname!r} translations from {modlocpath!r} " + f"({type(exc).__name__}: {exc}): falling back to English", + RuntimeWarning, + stacklevel=2, + ) + translation = gettext.NullTranslations() + return translation.gettext def get_module_locale_path(modname: str) -> str: diff --git a/guidata/tests/unit/test_translations.py b/guidata/tests/unit/test_translations.py index ae4cd382..adb36cdb 100644 --- a/guidata/tests/unit/test_translations.py +++ b/guidata/tests/unit/test_translations.py @@ -3,15 +3,88 @@ # Licensed under the terms of the BSD 3-Clause # (see guidata/LICENSE for details) -"""Little translation test""" +"""Translation tests + +Check that guidata translations are available, and that a missing, corrupted or +unreadable gettext catalog never prevents the application from starting: the +translation callback falls back to English (and warns if the catalog is corrupted). +""" # guitest: show +from __future__ import annotations + +import struct +import warnings + +import pytest + +from guidata import configtools from guidata.config import _ from guidata.env import execenv translations = (_("Some required entries are incorrect"),) +MODNAME = "guidata_test_catalog" +UTF8_HEADER = b"Content-Type: text/plain; charset=UTF-8\n" + + +def _build_mo(messages: dict[bytes, bytes]) -> bytes: + """Build a little-endian GNU gettext binary catalog + + Args: + messages: mapping of original strings to translated strings (the empty + original string holds the catalog header) + + Returns: + Binary content of the catalog + """ + keys = sorted(messages) + ids = strs = b"" + entries = [] + for key in keys: + entries.append((len(ids), len(key), len(strs), len(messages[key]))) + ids += key + b"\0" + strs += messages[key] + b"\0" + keystart = 7 * 4 + 16 * len(keys) + valuestart = keystart + len(ids) + koffsets, voffsets = [], [] + for id_off, id_len, str_off, str_len in entries: + koffsets += [id_len, id_off + keystart] + voffsets += [str_len, str_off + valuestart] + offsets = koffsets + voffsets + header = struct.pack( + "<7I", 0x950412DE, 0, len(keys), 7 * 4, 7 * 4 + 8 * len(keys), 0, 0 + ) + return header + struct.pack(f"<{len(offsets)}I", *offsets) + ids + strs + + +VALID_MO = _build_mo({b"": UTF8_HEADER, b"Hello": b"Bonjour"}) + +CORRUPTED_CATALOGS = { + "bad_magic_number": b"\0" * 28, + "empty_file": b"", + "truncated_header": VALID_MO[:10], + "offsets_past_end_of_file": VALID_MO[:-12], + "unknown_charset": _build_mo( + {b"": b"Content-Type: text/plain; charset=unknown-charset\n", b"Hello": b"X"} + ), + "invalid_utf8_translation": _build_mo({b"": UTF8_HEADER, b"Hello": b"\xff\xfe"}), + "invalid_plural_forms": _build_mo( + {b"": UTF8_HEADER + b"Plural-Forms: nplurals=2; plural=import os;\n"} + ), +} + + +@pytest.fixture(name="locale_dir") +def fixture_locale_dir(tmp_path, monkeypatch): + """Isolated French locale directory used by `get_translation`""" + monkeypatch.setattr(configtools, "get_module_locale_path", lambda _: str(tmp_path)) + monkeypatch.setenv("LANGUAGE", "fr") + msgdir = tmp_path / "fr" / "LC_MESSAGES" + msgdir.mkdir(parents=True) + return msgdir + def test_translations(): """Test translations""" @@ -20,5 +93,33 @@ def test_translations(): execenv.print("OK") +def test_valid_catalog(locale_dir): + """A valid catalog is used without warning""" + (locale_dir / f"{MODNAME}.mo").write_bytes(VALID_MO) + with warnings.catch_warnings(): + warnings.simplefilter("error") + translate = configtools.get_translation(MODNAME) + assert translate("Hello") == "Bonjour" + + +def test_missing_catalog(locale_dir): + """A missing catalog silently falls back to English""" + assert not any(locale_dir.iterdir()) + with warnings.catch_warnings(): + warnings.simplefilter("error") + translate = configtools.get_translation(MODNAME) + assert translate("Hello") == "Hello" + + +@pytest.mark.parametrize("content", CORRUPTED_CATALOGS.values(), ids=CORRUPTED_CATALOGS) +def test_corrupted_catalog(locale_dir, content): + """A corrupted catalog falls back to English with a warning""" + (locale_dir / f"{MODNAME}.mo").write_bytes(content) + with pytest.warns(RuntimeWarning, match="falling back to English") as record: + translate = configtools.get_translation(MODNAME) + assert MODNAME in str(record[0].message) + assert translate("Hello") == "Hello" + + if __name__ == "__main__": test_translations()