Repository: danhper/python-i18n Branch: master Commit: 75a49d6c4b8d Files: 38 Total size: 39.3 KB Directory structure: gitextract_0c1geu1c/ ├── .github/ │ └── workflows/ │ └── ci.yml ├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.md ├── i18n/ │ ├── __init__.py │ ├── config.py │ ├── loaders/ │ │ ├── __init__.py │ │ ├── json_loader.py │ │ ├── loader.py │ │ ├── python_loader.py │ │ └── yaml_loader.py │ ├── resource_loader.py │ ├── tests/ │ │ ├── __init__.py │ │ ├── loader_tests.py │ │ ├── resources/ │ │ │ ├── settings/ │ │ │ │ ├── dummy_config.json │ │ │ │ ├── dummy_config.py │ │ │ │ ├── dummy_config.yml │ │ │ │ └── eucjp_config.json │ │ │ └── translations/ │ │ │ ├── bar/ │ │ │ │ └── baz.en.json │ │ │ ├── en.json │ │ │ ├── foo.en.yml │ │ │ ├── foo.ja.yml │ │ │ ├── gb.json │ │ │ ├── ja.json │ │ │ └── nested_dict_json/ │ │ │ ├── en.json │ │ │ └── pl.json │ │ ├── run_tests.py │ │ └── translation_tests.py │ ├── translations.py │ └── translator.py ├── pyproject.toml ├── requirements/ │ ├── base.txt │ ├── dev.txt │ └── test.txt ├── requirements.txt └── setup.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: branches: [master] pull_request: branches: [master] jobs: build: runs-on: ubuntu-latest strategy: matrix: python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip pip install .[yaml] pip install ruff pyright - name: Lint with ruff run: ruff check i18n - name: Lint with pyright run: pyright . - name: Run tests run: python -m i18n.tests.run_tests ================================================ FILE: .gitignore ================================================ __pycache__/ *.egg-info/ build/ *.pyc *~ dist/ .python-version .idea/ .pytest_cache/ *.lock .ruff_cache .venv ================================================ FILE: .travis.yml ================================================ language: python python: - "2.7" - "3.6" - "3.7" - "3.8" # command to install dependencies install: "pip install -r requirements.txt" # command to run tests script: - python setup.py test - coverage run --source=i18n setup.py test after_success: - coveralls ================================================ FILE: LICENSE ================================================ Copyright (c) 2012 Daniel Perez Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: MANIFEST.in ================================================ include *.md include LICENSE include pyproject.toml include setup.py recursive-include requirements *.txt recursive-include i18n *.py recursive-include i18n *.json recursive-include i18n *.yml recursive-include i18n *.yaml ================================================ FILE: README.md ================================================ # python-i18n [![Build Status](https://github.com/danhper/python-i18n/actions/workflows/ci.yml/badge.svg)](https://github.com/danhper/python-i18n/actions/workflows/ci.yml) [![Coverage Status](https://coveralls.io/repos/github/danhper/python-i18n/badge.svg?branch=master)](https://coveralls.io/github/danhper/python-i18n?branch=master) [![Code Climate](https://codeclimate.com/github/danhper/python-i18n/badges/gpa.svg)](https://codeclimate.com/github/danhper/python-i18n) This library provides i18n functionality for Python 3 out of the box. The usage is mostly based on Rails i18n library. ## Installation Just run pip install python-i18n If you want to use YAML to store your translations, use pip install python-i18n[YAML] ## Usage ### Basic usage The simplest, though not very useful usage would be import i18n i18n.add_translation('foo', 'bar') i18n.t('foo') # bar ### Using translation files YAML and JSON formats are supported to store translations. With the default configuration, if you have the following `foo.en.yml` file en: hi: Hello world ! in `/path/to/translations` folder, you simply need to add the folder to the translations path. import i18n i18n.load_path.append('/path/to/translations') i18n.t('foo.hi') # Hello world ! Please note that YAML format is used as default file format if you have `yaml` module installed. If both `yaml` and `json` modules available and you want to use JSON to store translations, explicitly specify that: `i18n.set('file_format', 'json')` ### Memoization Setting the configuration value `enable_memoization` in the settings dir will load the files from disk the first time they are loaded and then store their content in memory. On the next use the file content will be provided from memory and not loaded from disk, preventing disk access. While this can be useful in some contexts, keep in mind there is no current way of issuing a command to the reloader to re-read the files from disk, so if you are updating your translation file without restarting the interpreter do not use this option. ### Namespaces #### File namespaces In the above example, the translation key is `foo.hi` and not just `hi`. This is because the translation filename format is by default `{namespace}.{locale}.{format}`, so the {namespace} part of the file is used as translation. To remove `{namespace}` from filename format please change the `filename_format` configuration. i18n.set('filename_format', '{locale}.{format}') #### Directory namespaces If your files are in subfolders, the foldernames are also used as namespaces, so for example if your translation root path is `/path/to/translations` and you have the file `/path/to/translations/my/app/name/foo.en.yml`, the translation namespace for the file will be `my.app.name` and the file keys will therefore be accessible from `my.app.name.foo.my_key`. ## Functionalities ### Placeholder You can of course use placeholders in your translations. With the default configuration, the placeholders are used by inserting `%{placeholder_name}` in the ntranslation string. Here is a sample usage. i18n.add_translation('hi', 'Hello %{name} !') i18n.t('hi', name='Bob') # Hello Bob ! ### Pluralization Pluralization is based on Rail i18n module. By passing a `count` variable to your translation, it will be pluralized. The translation value should be a dictionnary with at least the keys `one` and `many`. You can add a `zero` or `few` key when needed, if it is not present `many` will be used instead. Here is a sample usage. i18n.add_translation('mail_number', { 'zero': 'You do not have any mail.', 'one': 'You have a new mail.', 'few': 'You only have %{count} mails.', 'many': 'You have %{count} new mails.' }) i18n.t('mail_number', count=0) # You do not have any mail. i18n.t('mail_number', count=1) # You have a new mail. i18n.t('mail_number', count=3) # You only have 3 new mails. i18n.t('mail_number', count=12) # You have 12 new mails. ### Fallback You can set a fallback which will be used when the key is not found in the default locale. i18n.set('locale', 'jp') i18n.set('fallback', 'en') i18n.add_translation('foo', 'bar', locale='en') i18n.t('foo') # bar ### Skip locale from root Sometimes i18n structure file came from another project or not contains root element with locale eg. `en` name. { "foo": "FooBar" } However we would like to use this i18n .json file in our Python sub-project or micro service as base file for translations. `python-i18n` has special configuration tha is skipping locale eg. `en` root data element from the file. i18n.set('skip_locale_root_data', True) ================================================ FILE: i18n/__init__.py ================================================ from . import config, resource_loader from .resource_loader import I18nFileLoadError as I18nFileLoadError from .resource_loader import load_config as load_config from .resource_loader import register_loader as register_loader from .translations import add as add_translation # noqa: F401 from .translator import t as t resource_loader.init_loaders() load_path = config.get("load_path") ================================================ FILE: i18n/config.py ================================================ try: __import__("yaml") yaml_available = True except ImportError: yaml_available = False try: __import__("json") json_available = True except ImportError: json_available = False settings = { "filename_format": "{namespace}.{locale}.{format}", "file_format": "yml" if yaml_available else "json" if json_available else "py", "available_locales": ["en"], "load_path": [], "locale": "en", "fallback": "en", "placeholder_delimiter": "%", "error_on_missing_translation": False, "error_on_missing_placeholder": False, "error_on_missing_plural": False, "encoding": "utf-8", "namespace_delimiter": ".", "plural_few": 5, "skip_locale_root_data": False, "enable_memoization": False, } def set(key, value): settings[key] = value def get(key): return settings[key] ================================================ FILE: i18n/loaders/__init__.py ================================================ ================================================ FILE: i18n/loaders/json_loader.py ================================================ import json from .loader import I18nFileLoadError, Loader class JsonLoader(Loader): """class to load yaml files""" def __init__(self): super(JsonLoader, self).__init__() def parse_file(self, file_content): try: return json.loads(file_content) except ValueError as e: raise I18nFileLoadError("invalid JSON: {0}".format(str(e))) ================================================ FILE: i18n/loaders/loader.py ================================================ import io from .. import config class I18nFileLoadError(Exception): def __init__(self, value): self.value = value def __str__(self): return str(self.value) class Loader(object): """Base class to load resources""" def __init__(self): super(Loader, self).__init__() self.memoization_dict = {} def _load_file_data(self, filename): try: with io.open(filename, "r", encoding=config.get("encoding")) as f: return f.read() except IOError as e: raise I18nFileLoadError( "error loading file {0}: {1}".format(filename, e.strerror) ) def load_file(self, filename): enable_memoization = config.get("enable_memoization") if enable_memoization: if filename not in self.memoization_dict: self.memoization_dict[filename] = self._load_file_data(filename) return self.memoization_dict[filename] else: return self._load_file_data(filename) def parse_file(self, file_content): raise NotImplementedError( "the method parse_file has not been implemented for class {0}".format( self.__class__.__name__ ) ) def check_data(self, data, root_data): return True if root_data is None else root_data in data def get_data(self, data, root_data): return data if root_data is None else data[root_data] def load_resource(self, filename, root_data): file_content = self.load_file(filename) data = self.parse_file(file_content) if not self.check_data(data, root_data): raise I18nFileLoadError( "error getting data from {0}: {1} not defined".format( filename, root_data ) ) return self.get_data(data, root_data) ================================================ FILE: i18n/loaders/python_loader.py ================================================ import os.path import sys from .loader import I18nFileLoadError, Loader class PythonLoader(Loader): """class to load python files""" def __init__(self): super(PythonLoader, self).__init__() def load_file(self, filename): path, name = os.path.split(filename) module_name, ext = os.path.splitext(name) if path not in sys.path: sys.path.append(path) try: return __import__(module_name) except ImportError: raise I18nFileLoadError("error loading file {0}".format(filename)) def parse_file(self, file_content): return file_content def check_data(self, data, root_data): return hasattr(data, root_data) def get_data(self, data, root_data): return getattr(data, root_data) ================================================ FILE: i18n/loaders/yaml_loader.py ================================================ import yaml from .loader import I18nFileLoadError, Loader class YamlLoader(Loader): """class to load yaml files""" def __init__(self): super(YamlLoader, self).__init__() def parse_file(self, file_content): try: loader = getattr(yaml, "FullLoader", yaml.SafeLoader) return yaml.load(file_content, Loader=loader) except Exception as e: raise I18nFileLoadError("invalid YAML: {0}".format(str(e))) ================================================ FILE: i18n/resource_loader.py ================================================ import os.path from . import config, translations from .loaders.loader import I18nFileLoadError loaders = {} PLURALS = ["zero", "one", "few", "many", "other"] def register_loader(loader_class, supported_extensions): if not hasattr(loader_class, "load_resource"): raise ValueError("loader class should have a 'load_resource' method") for extension in supported_extensions: loaders[extension] = loader_class() def load_resource(filename, root_data): extension = os.path.splitext(filename)[1][1:] if extension not in loaders: raise I18nFileLoadError( "no loader available for extension {0}".format(extension) ) return getattr(loaders[extension], "load_resource")(filename, root_data) def init_loaders(): init_python_loader() if config.yaml_available: init_yaml_loader() if config.json_available: init_json_loader() def init_python_loader(): from .loaders.python_loader import PythonLoader register_loader(PythonLoader, ["py"]) def init_yaml_loader(): from .loaders.yaml_loader import YamlLoader register_loader(YamlLoader, ["yml", "yaml"]) def init_json_loader(): from .loaders.json_loader import JsonLoader register_loader(JsonLoader, ["json"]) def load_config(filename): settings_data = load_resource(filename, "settings") for key, value in settings_data.items(): config.settings[key] = value def get_namespace_from_filepath(filename): namespace = ( os.path.dirname(filename) .strip(os.sep) .replace(os.sep, config.get("namespace_delimiter")) ) if "{namespace}" in config.get("filename_format"): try: splitted_filename = os.path.basename(filename).split(".") if namespace: namespace += config.get("namespace_delimiter") namespace += splitted_filename[ config.get("filename_format").index("{namespace}") ] except ValueError: raise I18nFileLoadError("incorrect file format.") return namespace def load_translation_file(filename, base_directory, locale=config.get("locale")): skip_locale_root_data = config.get("skip_locale_root_data") root_data = None if skip_locale_root_data else locale translations_dic = load_resource(os.path.join(base_directory, filename), root_data) namespace = get_namespace_from_filepath(filename) load_translation_dic(translations_dic, namespace, locale) def load_translation_dic(dic, namespace, locale): if namespace: namespace += config.get("namespace_delimiter") for key, value in dic.items(): if isinstance(value, dict) and len(set(PLURALS).intersection(value)) < 2: load_translation_dic(value, namespace + key, locale) else: translations.add(namespace + key, value, locale) def load_directory(directory, locale=config.get("locale")): for f in os.listdir(directory): path = os.path.join(directory, f) if os.path.isfile(path) and path.endswith(config.get("file_format")): if "{locale}" in config.get("filename_format") and locale not in f: continue load_translation_file(f, directory, locale) def search_translation(key, locale=config.get("locale")): splitted_key = key.split(config.get("namespace_delimiter")) if not splitted_key: return namespace = splitted_key[:-1] if not namespace and "{namespace}" not in config.get("filename_format"): for directory in config.get("load_path"): load_directory(directory, locale) else: for directory in config.get("load_path"): recursive_search_dir(namespace, "", directory, locale) def recursive_search_dir( splitted_namespace, directory, root_dir, locale=config.get("locale") ): if not splitted_namespace: return seeked_file = config.get("filename_format").format( namespace=splitted_namespace[0], format=config.get("file_format"), locale=locale ) dir_content = os.listdir(os.path.join(root_dir, directory)) if seeked_file in dir_content: load_translation_file(os.path.join(directory, seeked_file), root_dir, locale) elif splitted_namespace[0] in dir_content: recursive_search_dir( splitted_namespace[1:], os.path.join(directory, splitted_namespace[0]), root_dir, locale, ) ================================================ FILE: i18n/tests/__init__.py ================================================ ================================================ FILE: i18n/tests/loader_tests.py ================================================ # -*- encoding: utf-8 -*- from __future__ import unicode_literals import os import os.path import tempfile import unittest # Python 3 only: always import reload from importlib from importlib import reload from i18n import config, resource_loader, translations from i18n.config import json_available, yaml_available from i18n.resource_loader import I18nFileLoadError from i18n.translator import t RESOURCE_FOLDER = os.path.join(os.path.dirname(__file__), "resources") class TestFileLoader(unittest.TestCase): def setUp(self): resource_loader.loaders = {} translations.container = {} reload(config) config.set("load_path", [os.path.join(RESOURCE_FOLDER, "translations")]) config.set("filename_format", "{namespace}.{locale}.{format}") config.set("encoding", "utf-8") def test_load_unavailable_extension(self): with self.assertRaisesRegex(I18nFileLoadError, "no loader .*"): resource_loader.load_resource("foo.bar", "baz") with self.assertRaisesRegex(I18nFileLoadError, "no loader .*"): resource_loader.load_resource("foo.bar", "baz") def test_register_wrong_loader(self): class WrongLoader(object): pass with self.assertRaises(ValueError): resource_loader.register_loader(WrongLoader, []) def test_register_python_loader(self): resource_loader.init_python_loader() with self.assertRaisesRegex(I18nFileLoadError, "error loading file .*"): resource_loader.load_resource("foo.py", "bar") with self.assertRaisesRegex(I18nFileLoadError, "error loading file .*"): resource_loader.load_resource("foo.py", "bar") @unittest.skipUnless(yaml_available, "yaml library not available") def test_register_yaml_loader(self): resource_loader.init_yaml_loader() with self.assertRaisesRegex(I18nFileLoadError, "error loading file .*"): resource_loader.load_resource("foo.yml", "bar") with self.assertRaisesRegex(I18nFileLoadError, "error loading file .*"): resource_loader.load_resource("foo.yml", "bar") @unittest.skipUnless(json_available, "json library not available") def test_load_wrong_json_file(self): resource_loader.init_json_loader() with self.assertRaisesRegex(I18nFileLoadError, "error getting data .*"): resource_loader.load_resource( os.path.join(RESOURCE_FOLDER, "settings", "dummy_config.json"), "foo" ) with self.assertRaisesRegex(I18nFileLoadError, "error getting data .*"): resource_loader.load_resource( os.path.join(RESOURCE_FOLDER, "settings", "dummy_config.json"), "foo", ) @unittest.skipUnless(yaml_available, "yaml library not available") def test_load_yaml_file(self): resource_loader.init_yaml_loader() data = resource_loader.load_resource( os.path.join(RESOURCE_FOLDER, "settings", "dummy_config.yml"), "settings" ) self.assertIn("foo", data) self.assertEqual("bar", data["foo"]) @unittest.skipUnless(json_available, "json library not available") def test_load_json_file(self): resource_loader.init_json_loader() data = resource_loader.load_resource( os.path.join(RESOURCE_FOLDER, "settings", "dummy_config.json"), "settings" ) self.assertIn("foo", data) self.assertEqual("bar", data["foo"]) def test_load_python_file(self): resource_loader.init_python_loader() data = resource_loader.load_resource( os.path.join(RESOURCE_FOLDER, "settings", "dummy_config.py"), "settings" ) self.assertIn("foo", data) self.assertEqual("bar", data["foo"]) @unittest.skipUnless(yaml_available, "yaml library not available") def test_memoization_with_file(self): """This test creates a temporary file with the help of the tempfile library and writes a simple key: value dictionary in it. It will then use that file to load the translations and, after having enabled memoization, try to access it, causing the file to be (hopefully) memoized. It will then _remove_ the temporary file and try to access again, asserting that an error is not raised, thus making sure the data is actually loaded from memory and not from disk access.""" memoization_file_name = "memoize.en.yml" # create the file and write the data in it try: d = tempfile.TemporaryDirectory() tmp_dir_name = d.name except AttributeError: # we are running python2, use mkdtemp tmp_dir_name = tempfile.mkdtemp() fd = open("{}/{}".format(tmp_dir_name, memoization_file_name), "w") fd.write("en:\n key: value") fd.close() # create the loader and pass the file to it resource_loader.init_yaml_loader() resource_loader.load_translation_file(memoization_file_name, tmp_dir_name) # try loading the value to make sure it's working self.assertEqual(t("memoize.key"), "value") # now delete the file and directory # we are running python2, delete manually import shutil shutil.rmtree(tmp_dir_name) # test the translation again to make sure it's loaded from memory self.assertEqual(t("memoize.key"), "value") @unittest.skipUnless(json_available, "json library not available") def test_load_file_with_strange_encoding(self): resource_loader.init_json_loader() config.set("encoding", "euc-jp") data = resource_loader.load_resource( os.path.join(RESOURCE_FOLDER, "settings", "eucjp_config.json"), "settings" ) self.assertIn("ほげ", data) self.assertEqual("ホゲ", data["ほげ"]) def test_get_namespace_from_filepath_with_filename(self): tests = { "foo": "foo.ja.yml", "foo.bar": os.path.join("foo", "bar.ja.yml"), "foo.bar.baz": os.path.join("foo", "bar", "baz.en.yml"), } for expected, test_val in tests.items(): namespace = resource_loader.get_namespace_from_filepath(test_val) self.assertEqual(expected, namespace) def test_get_namespace_from_filepath_without_filename(self): tests = { "": "ja.yml", "foo": os.path.join("foo", "ja.yml"), "foo.bar": os.path.join("foo", "bar", "en.yml"), } config.set("filename_format", "{locale}.{format}") for expected, test_val in tests.items(): namespace = resource_loader.get_namespace_from_filepath(test_val) self.assertEqual(expected, namespace) @unittest.skipUnless(yaml_available, "yaml library not available") def test_load_translation_file(self): resource_loader.init_yaml_loader() resource_loader.load_translation_file( "foo.en.yml", os.path.join(RESOURCE_FOLDER, "translations") ) self.assertTrue(translations.has("foo.normal_key")) self.assertTrue(translations.has("foo.parent.nested_key")) @unittest.skipUnless(json_available, "json library not available") def test_load_plural(self): resource_loader.init_yaml_loader() resource_loader.load_translation_file( "foo.en.yml", os.path.join(RESOURCE_FOLDER, "translations") ) self.assertTrue(translations.has("foo.mail_number")) translated_plural = translations.get("foo.mail_number") self.assertIsInstance(translated_plural, dict) self.assertEqual(translated_plural["zero"], "You do not have any mail.") self.assertEqual(translated_plural["one"], "You have a new mail.") self.assertEqual(translated_plural["many"], "You have %{count} new mails.") @unittest.skipUnless(yaml_available, "yaml library not available") def test_search_translation_yaml(self): resource_loader.init_yaml_loader() config.set("file_format", "yml") resource_loader.search_translation("foo.normal_key") self.assertTrue(translations.has("foo.normal_key")) @unittest.skipUnless(json_available, "json library not available") def test_search_translation_json(self): resource_loader.init_json_loader() config.set("file_format", "json") resource_loader.search_translation("bar.baz.qux") self.assertTrue(translations.has("bar.baz.qux")) @unittest.skipUnless(json_available, "json library not available") def test_search_translation_without_ns(self): resource_loader.init_json_loader() config.set("file_format", "json") config.set("filename_format", "{locale}.{format}") resource_loader.search_translation("foo") self.assertTrue(translations.has("foo")) @unittest.skipUnless(json_available, "json library not available") def test_search_translation_without_ns_nested_dict__two_levels_neting__default_locale( self, ): resource_loader.init_json_loader() config.set("file_format", "json") config.set( "load_path", [os.path.join(RESOURCE_FOLDER, "translations", "nested_dict_json")], ) config.set("filename_format", "{locale}.{format}") config.set("skip_locale_root_data", True) config.set("locale", ["en", "pl"]) resource_loader.search_translation("COMMON.VERSION") self.assertTrue(translations.has("COMMON.VERSION")) self.assertEqual(translations.get("COMMON.VERSION"), "version") @unittest.skipUnless(json_available, "json library not available") def test_search_translation_without_ns_nested_dict__two_levels_neting__other_locale( self, ): resource_loader.init_json_loader() config.set("file_format", "json") config.set( "load_path", [os.path.join(RESOURCE_FOLDER, "translations", "nested_dict_json")], ) config.set("filename_format", "{locale}.{format}") config.set("skip_locale_root_data", True) config.set("locale", ["en", "pl"]) resource_loader.search_translation("COMMON.VERSION", locale="pl") self.assertTrue(translations.has("COMMON.VERSION", locale="pl")) self.assertEqual(translations.get("COMMON.VERSION", locale="pl"), "wersja") @unittest.skipUnless(json_available, "json library not available") def test_search_translation_without_ns_nested_dict__default_locale(self): resource_loader.init_json_loader() config.set("file_format", "json") config.set( "load_path", [os.path.join(RESOURCE_FOLDER, "translations", "nested_dict_json")], ) config.set("filename_format", "{locale}.{format}") config.set("skip_locale_root_data", True) config.set("locale", "en") resource_loader.search_translation("TOP_MENU.TOP_BAR.LOGS") self.assertTrue(translations.has("TOP_MENU.TOP_BAR.LOGS")) self.assertEqual(translations.get("TOP_MENU.TOP_BAR.LOGS"), "Logs") @unittest.skipUnless(json_available, "json library not available") def test_search_translation_without_ns_nested_dict__other_locale(self): resource_loader.init_json_loader() config.set("file_format", "json") config.set( "load_path", [os.path.join(RESOURCE_FOLDER, "translations", "nested_dict_json")], ) config.set("filename_format", "{locale}.{format}") config.set("skip_locale_root_data", True) config.set("locale", "en") resource_loader.search_translation("TOP_MENU.TOP_BAR.LOGS", locale="pl") self.assertTrue(translations.has("TOP_MENU.TOP_BAR.LOGS", locale="pl")) self.assertEqual(translations.get("TOP_MENU.TOP_BAR.LOGS", locale="pl"), "Logi") suite = unittest.TestLoader().loadTestsFromTestCase(TestFileLoader) unittest.TextTestRunner(verbosity=2).run(suite) ================================================ FILE: i18n/tests/resources/settings/dummy_config.json ================================================ { "settings" : { "foo": "bar", "baz": "qux" } } ================================================ FILE: i18n/tests/resources/settings/dummy_config.py ================================================ settings = {"foo": "bar", "baz": "qux"} ================================================ FILE: i18n/tests/resources/settings/dummy_config.yml ================================================ settings: foo: bar baz: qux ================================================ FILE: i18n/tests/resources/settings/eucjp_config.json ================================================ { "settings": { "ۤ": "ۥ" } } ================================================ FILE: i18n/tests/resources/translations/bar/baz.en.json ================================================ { "en": { "qux": "hoge" } } ================================================ FILE: i18n/tests/resources/translations/en.json ================================================ { "en": { "foo": "FooBar" } } ================================================ FILE: i18n/tests/resources/translations/foo.en.yml ================================================ en: normal_key: normal_value parent: nested_key: nested_value mail_number: zero: You do not have any mail. one: You have a new mail. few: You only have %{count} mails. many: You have %{count} new mails. ================================================ FILE: i18n/tests/resources/translations/foo.ja.yml ================================================ ja: normal_key: 普通 fallback_key: フォールバック ================================================ FILE: i18n/tests/resources/translations/gb.json ================================================ { "foo": "Lorry" } ================================================ FILE: i18n/tests/resources/translations/ja.json ================================================ { "ja": { "foo": "ふぉお", "bar": "ばば" } } ================================================ FILE: i18n/tests/resources/translations/nested_dict_json/en.json ================================================ { "TOP_MENU": { "TOP_BAR": { "LOGS": "Logs", "LOGS_ITEMS": { "MAIN": "Main process", "ERROR": "Errors", "CUSTOM": "Custom Logs" }, "NOTIFICATIONS": "Notifications", "CONFIG": "Config", "LICENSE": "License" } }, "COMMON": { "HOME": "Home", "START": "Start", "STOP": "Stop", "REFRESH": "Refresh", "LOAD_FILE": "Load file", "DELETE_FILE": "Delete file", "EXECUTE": "Execute", "DELETE": "Delete", "VERSION": "version", "SUBMIT": "Submit" } } ================================================ FILE: i18n/tests/resources/translations/nested_dict_json/pl.json ================================================ { "TOP_MENU": { "TOP_BAR": { "LOGS": "Logi", "LOGS_ITEMS": { "MAIN": "Główny proces", "ERROR": "Błędy", "CUSTOM": "Logi dodatkowe" }, "NOTIFICATIONS": "Powiadomienia", "CONFIG": "Konfiguracja", "LICENSE": "Licencja" } }, "COMMON": { "HOME": "Początek", "START": "Start", "STOP": "Stop", "REFRESH": "Odśwież", "LOAD_FILE": "Załaduj", "DELETE_FILE": "Usuń", "EXECUTE": "Wykonaj", "DELETE": "Usuń", "VERSION": "wersja", "SUBMIT": "Wyślij" } } ================================================ FILE: i18n/tests/run_tests.py ================================================ import unittest from i18n.tests.loader_tests import TestFileLoader from i18n.tests.translation_tests import TestTranslationFormat def suite(): suite = unittest.TestSuite() loader = unittest.TestLoader() suite.addTest(loader.loadTestsFromTestCase(TestFileLoader)) suite.addTest(loader.loadTestsFromTestCase(TestTranslationFormat)) return suite if __name__ == "__main__": runner = unittest.TextTestRunner() test_suite = suite() runner.run(test_suite) ================================================ FILE: i18n/tests/translation_tests.py ================================================ # -*- encoding: utf-8 -*- from __future__ import unicode_literals import os import os.path import unittest # Python 3 only: always import reload from importlib from importlib import reload from i18n import config, resource_loader, translations from i18n.translator import t RESOURCE_FOLDER = os.path.dirname(__file__) + os.sep + "resources" + os.sep class TestTranslationFormat(unittest.TestCase): @classmethod def setUpClass(cls): resource_loader.init_loaders() reload(config) config.set("load_path", [os.path.join(RESOURCE_FOLDER, "translations")]) translations.add("foo.hi", "Hello %{name} !") translations.add("foo.hello", "Salut %{name} !", locale="fr") translations.add( "foo.basic_plural", {"one": "1 elem", "many": "%{count} elems"} ) translations.add( "foo.plural", { "zero": "no mail", "one": "1 mail", "few": "only %{count} mails", "many": "%{count} mails", }, ) translations.add("foo.bad_plural", {"bar": "foo elems"}) def setUp(self): config.set("error_on_missing_translation", False) config.set("error_on_missing_placeholder", False) config.set("fallback", "en") config.set("locale", "en") def test_basic_translation(self): self.assertEqual(t("foo.normal_key"), "normal_value") def test_missing_translation(self): self.assertEqual(t("foo.inexistent"), "foo.inexistent") def test_missing_translation_error(self): config.set("error_on_missing_translation", True) with self.assertRaises(KeyError): t("foo.inexistent") def test_locale_change(self): config.set("locale", "fr") self.assertEqual(t("foo.hello", name="Bob"), "Salut Bob !") def test_fallback(self): config.set("fallback", "fr") self.assertEqual(t("foo.hello", name="Bob"), "Salut Bob !") def test_fallback_from_resource(self): config.set("fallback", "ja") self.assertEqual(t("foo.fallback_key"), "フォールバック") def test_basic_placeholder(self): self.assertEqual(t("foo.hi", name="Bob"), "Hello Bob !") def test_missing_placehoder(self): self.assertEqual(t("foo.hi"), "Hello %{name} !") def test_missing_placeholder_error(self): config.set("error_on_missing_placeholder", True) with self.assertRaises(KeyError): t("foo.hi") def test_basic_pluralization(self): self.assertEqual(t("foo.basic_plural", count=0), "0 elems") self.assertEqual(t("foo.basic_plural", count=1), "1 elem") self.assertEqual(t("foo.basic_plural", count=2), "2 elems") def test_full_pluralization(self): self.assertEqual(t("foo.plural", count=0), "no mail") self.assertEqual(t("foo.plural", count=1), "1 mail") self.assertEqual(t("foo.plural", count=4), "only 4 mails") self.assertEqual(t("foo.plural", count=12), "12 mails") def test_bad_pluralization(self): config.set("error_on_missing_plural", False) self.assertEqual(t("foo.normal_key", count=5), "normal_value") config.set("error_on_missing_plural", True) with self.assertRaises(KeyError): t("foo.bad_plural", count=0) def test_default(self): self.assertEqual(t("inexistent_key", default="foo"), "foo") def test_skip_locale_root_data(self): config.set("filename_format", "{locale}.{format}") config.set("file_format", "json") config.set("locale", "gb") config.set("skip_locale_root_data", True) resource_loader.init_loaders() self.assertEqual(t("foo"), "Lorry") config.set("skip_locale_root_data", False) def test_skip_locale_root_data_nested_json_dict__default_locale(self): config.set("file_format", "json") config.set( "load_path", [os.path.join(RESOURCE_FOLDER, "translations", "nested_dict_json")], ) config.set("filename_format", "{locale}.{format}") config.set("skip_locale_root_data", True) config.set("locale", "en") resource_loader.init_json_loader() self.assertEqual(t("COMMON.START"), "Start") def test_skip_locale_root_data_nested_json_dict__other_locale(self): config.set("file_format", "json") config.set( "load_path", [os.path.join(RESOURCE_FOLDER, "translations", "nested_dict_json")], ) config.set("filename_format", "{locale}.{format}") config.set("skip_locale_root_data", True) config.set("locale", "en") resource_loader.init_json_loader() self.assertEqual(t("COMMON.EXECUTE", locale="pl"), "Wykonaj") ================================================ FILE: i18n/translations.py ================================================ from . import config container = {} def add(key, value, locale=config.get("locale")): container.setdefault(locale, {})[key] = value def has(key, locale=config.get("locale")): return key in container.get(locale, {}) def get(key, locale=config.get("locale")): return container[locale][key] ================================================ FILE: i18n/translator.py ================================================ from string import Template from . import config, resource_loader, translations class TranslationFormatter(Template): delimiter = config.get("placeholder_delimiter") def __init__(self, template): super(TranslationFormatter, self).__init__(template) def format(self, **kwargs): if config.get("error_on_missing_placeholder"): return self.substitute(**kwargs) else: return self.safe_substitute(**kwargs) def t(key, **kwargs): locale = kwargs.pop("locale", config.get("locale")) if translations.has(key, locale): return translate(key, locale=locale, **kwargs) else: resource_loader.search_translation(key, locale) if translations.has(key, locale): return translate(key, locale=locale, **kwargs) elif locale != config.get("fallback"): return t(key, locale=config.get("fallback"), **kwargs) if "default" in kwargs: return kwargs["default"] if config.get("error_on_missing_translation"): raise KeyError("key {0} not found".format(key)) else: return key def translate(key, **kwargs): locale = kwargs.pop("locale", config.get("locale")) translation = translations.get(key, locale=locale) if "count" in kwargs: translation = pluralize(key, translation, kwargs["count"]) return TranslationFormatter(translation).format(**kwargs) def pluralize(key, translation, count): return_value = key try: if not isinstance(translation, dict): return_value = translation raise KeyError("use of count witouth dict for key {0}".format(key)) if count == 0: if "zero" in translation: return translation["zero"] elif count == 1: if "one" in translation: return translation["one"] elif count <= config.get("plural_few"): if "few" in translation: return translation["few"] # TODO: deprecate other if "other" in translation: return translation["other"] if "many" in translation: return translation["many"] else: raise KeyError('"many" not defined for key {0}'.format(key)) except KeyError as e: if config.get("error_on_missing_plural"): raise e else: return return_value ================================================ FILE: pyproject.toml ================================================ [build-system] requires = ["setuptools>=61.0", "wheel"] build-backend = "setuptools.build_meta" [project] name = "python-i18n" version = "0.3.9" description = "Translation library for Python" readme = "README.md" authors = [ { name = "Daniel Perez", email = "tuvistavie@gmail.com" } ] license = { file = "LICENSE" } requires-python = ">=3.7" dependencies = [ "pyyaml>=3.10" ] classifiers = [ "Development Status :: 4 - Beta", "Environment :: Other Environment", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Topic :: Software Development :: Libraries" ] [project.optional-dependencies] yaml = ["pyyaml>=3.10"] [tool.setuptools.packages.find] where = ["."] include = ["i18n*", "i18n.loaders*", "i18n.tests*"] ================================================ FILE: requirements/base.txt ================================================ pyyaml>=3.10 ================================================ FILE: requirements/dev.txt ================================================ -r base.txt coveralls ================================================ FILE: requirements/test.txt ================================================ -r dev.txt pytest ================================================ FILE: requirements.txt ================================================ pyyaml>=3.10 coveralls ================================================ FILE: setup.py ================================================ from setuptools import setup # type: ignore setup( name="python-i18n", version="0.3.9", description="Translation library for Python", long_description=open("README.md").read(), long_description_content_type="text/markdown", author="Daniel Perez", author_email="tuvistavie@gmail.com", url="https://github.com/tuvistavie/python-i18n", download_url="https://github.com/tuvistavie/python-i18n/archive/master.zip", license="MIT", packages=["i18n", "i18n.loaders", "i18n.tests"], include_package_data=True, zip_safe=True, test_suite="i18n.tests", extras_require={ "YAML": ["pyyaml>=3.10"], }, classifiers=[ "Development Status :: 4 - Beta", "Environment :: Other Environment", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Topic :: Software Development :: Libraries", ], )