Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions doc/release_notes/release_3.16.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
22 changes: 21 additions & 1 deletion guidata/configtools.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
import gettext
import os
import os.path as osp
import struct
import sys
import warnings
from collections.abc import Callable
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
103 changes: 102 additions & 1 deletion guidata/tests/unit/test_translations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
Expand All @@ -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()
Loading