diff --git a/Lib/gettext.py b/Lib/gettext.py index 2f77f0e849e9ae..0cb821db5bc2ee 100644 --- a/Lib/gettext.py +++ b/Lib/gettext.py @@ -484,6 +484,13 @@ def npgettext(self, context, msgid1, msgid2, n): tmsg = msgid2 return tmsg +# Path objects also implement Traversable, but they work with legacy APIs. +# Only return True for non-path Traversable objects that truly need the Traversable API. +def _needs_traversable_api(file): + if not isinstance(file, str | os.PathLike): + from importlib.resources.abc import Traversable + return isinstance(file, Traversable) + return False # Locate a .mo file using the gettext strategy def find(domain, localedir=None, languages=None, all=False): @@ -513,8 +520,14 @@ def find(domain, localedir=None, languages=None, all=False): for lang in nelangs: if lang == 'C': break - mofile = os.path.join(localedir, lang, 'LC_MESSAGES', '%s.mo' % domain) - if os.path.exists(mofile): + if _needs_traversable_api(localedir): + mofile = localedir.joinpath(lang, 'LC_MESSAGES', '%s.mo' % domain) + is_exists= mofile.is_file() + else: + mofile = os.path.join(localedir, lang, 'LC_MESSAGES', '%s.mo' % domain) + is_exists = os.path.exists(mofile) + + if is_exists: if all: result.append(mofile) else: @@ -541,10 +554,14 @@ def translation(domain, localedir=None, languages=None, # once. result = None for mofile in mofiles: - key = (class_, os.path.abspath(mofile)) + use_traversable_api = _needs_traversable_api(mofile) + if not use_traversable_api: + mofile = os.path.abspath(mofile) + + key = (class_, mofile) t = _translations.get(key) if t is None: - with open(mofile, 'rb') as fp: + with (mofile.open('rb') if use_traversable_api else open(mofile, 'rb')) as fp: t = _translations.setdefault(key, class_(fp)) # Copy the translation object to allow setting fallbacks and # output charset. All other instance data is shared with the diff --git a/Lib/test/test_gettext.py b/Lib/test/test_gettext.py index 9ad37909a8ec4e..acb631ffef9b9a 100644 --- a/Lib/test/test_gettext.py +++ b/Lib/test/test_gettext.py @@ -1,3 +1,6 @@ +from collections.abc import Iterator +from importlib.resources.abc import Traversable +import io import os import base64 import gettext @@ -208,6 +211,73 @@ def setUp(self): Ri04CgA= ''' + +ROOT_VFS = { + "locale": { + "ga_IE": {"LC_MESSAGES": {"mofile.mo": GNU_MO_DATA}}, + "es_ES": {"LC_MESSAGES": {"mofile.mo": GNU_MO_DATA}}, + }, +} + + +class MockTraversable(Traversable): + def __init__(self, path, vfs): + self.path = path + self.vfs = vfs + + def __str__(self) -> str: + return self.path + + @property + def name(self): + return self.path.split('/')[-1] + + def joinpath(self, *args): + vfs = self.vfs + for name in args: + if vfs: + vfs = vfs.get(name) + return MockTraversable(self.path + "/" + "/".join(args), vfs) + + def is_file(self): + return isinstance(self.vfs, bytes) + + def open(self, mode='r', *args, **kwargs): + if "b" in mode: + return io.BytesIO(base64.decodebytes(self.vfs)) + else: + return io.StringIO(base64.decodebytes(self.vfs).decode(encoding=kwargs.get('encoding', 'utf-8'))) + + def is_dir(self): + return isinstance(self.vfs, dict) + + def iterdir(self) -> Iterator[Traversable]: + for name in self.vfs.keys(): + yield self.joinpath(name) + + def read_bytes(self) -> bytes: + with self.open('rb') as strm: + return strm.read() + + def read_text(self, encoding) -> str: + with self.open(encoding=encoding) as strm: + return strm.read() + + def __eq__(self, value: object) -> bool: + if not isinstance(value, MockTraversable): + return False + return self.path == value.path + + def __lt__(self, other): + return self.path < other.path + + def __gt__(self, other): + return self.path > other.path + + def __hash__(self): + return hash(self.path) + + class GettextTestCase1(GettextBaseTest): def setUp(self): GettextBaseTest.setUp(self) @@ -926,6 +996,14 @@ def test_find_deduplication(self): languages=['ga_IE', 'ga_IE'], all=True) self.assertEqual(result, mo_file) + def test_find_with_traversable_directory(self): + traversable_dir = MockTraversable("/", ROOT_VFS) + result = gettext.find('mofile', + localedir=traversable_dir.joinpath("locale"), + languages=["ga_IE", "es_ES"], all=True) + self.assertEqual(sorted(result), sorted((traversable_dir.joinpath("locale", "ga_IE", "LC_MESSAGES", "mofile.mo"), + traversable_dir.joinpath("locale", "es_ES", "LC_MESSAGES", "mofile.mo")))) + class MiscTestCase(unittest.TestCase): def test__all__(self): @@ -934,15 +1012,31 @@ def test__all__(self): @cpython_only def test_lazy_import(self): - ensure_lazy_imports("gettext", {"re", "warnings", "locale"}) + ensure_lazy_imports("gettext", {"re", "warnings", "locale", "importlib.resources.abc"}) -class TranslationFallbackTestCase(unittest.TestCase): +class TranslationTestCase(unittest.TestCase): def test_translation_fallback(self): with os_helper.temp_cwd() as tempdir: t = gettext.translation('gettext', localedir=tempdir, fallback=True) self.assertIsInstance(t, gettext.NullTranslations) + def test_translation_with_traversable_directory(self): + traversable_dir = MockTraversable("/", ROOT_VFS) + trans = gettext.translation('mofile', localedir=traversable_dir.joinpath( + "locale"), languages=["ga_IE", "es_ES"]) + self.assertIsInstance(trans, gettext.GNUTranslations) + + +class NeedsTraversableApiTestCase(unittest.TestCase): + def test_needs_traversable_api_function(self): + from pathlib import Path + from gettext import _needs_traversable_api + + self.assertFalse(_needs_traversable_api("some/path")) + self.assertFalse(_needs_traversable_api(Path("some/path"))) + self.assertFalse(_needs_traversable_api(Path("some/path"))) + if __name__ == '__main__': unittest.main() diff --git a/Misc/NEWS.d/next/Library/2026-05-14-06-29-49.gh-issue-149809.juDEkc.rst b/Misc/NEWS.d/next/Library/2026-05-14-06-29-49.gh-issue-149809.juDEkc.rst new file mode 100644 index 00000000000000..e9071908ad5dd2 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-05-14-06-29-49.gh-issue-149809.juDEkc.rst @@ -0,0 +1 @@ +Make the gettext functions accept a localedir of type Traversable.