From fdee71e453e6157a2cdb2f48f4fd08b2ad27c17d Mon Sep 17 00:00:00 2001 From: Krutyi 4el <60041069+Krutyi-4el@users.noreply.github.com> Date: Sat, 12 Aug 2023 11:56:49 +0300 Subject: [PATCH 01/13] improve dev-helper --- .github/workflows/ci.yml | 4 ---- dev-helper.py | 45 +++++++++++++++++++++++----------------- 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 201a8d4..f01279a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,8 +29,6 @@ jobs: python-version: ${{ matrix.python-version }} - name: Run tests run: python dev-helper.py run-tests - env: - SKIP_VENV: 1 - name: Upload coverage # PyPy data isn't reliable because it changes trace function if: "!startsWith(matrix.python-version, 'pypy')" @@ -48,5 +46,3 @@ jobs: python-version: "3.11" - name: Run checks run: python dev-helper.py run-checks - env: - SKIP_VENV: 1 diff --git a/dev-helper.py b/dev-helper.py index 725e0ce..7dca135 100644 --- a/dev-helper.py +++ b/dev-helper.py @@ -1,3 +1,10 @@ +""" +This script automates testing and code quality checks +It stashes unstaged changes before testing to ensure accurate results +It also automatically manages virtual environment and dependencies +Run "python dev-helper.py install" to install it as pre-commit hook in this repository +""" + import os import sys import stat @@ -9,6 +16,8 @@ VENV_DIR = "dev_env" REQUIREMENTS = "dev-requirements.txt" +ON_CI = bool(os.getenv("CI")) +SKIP_VENV = ON_CI or os.getenv("SKIP_VENV") == "1" def get_root(): @@ -24,25 +33,23 @@ def get_root(): def ensure_venv(packages): venv_dir = os.path.join(os.getcwd(), VENV_DIR) - if sys.prefix == venv_dir: - # already in venv - return - if os.getenv("SKIP_VENV") != "1": - if not os.path.isdir(venv_dir): - print("Creating virtual environment...") - subprocess.run((sys.executable, "-m", "venv", venv_dir), check=True) - python_path = os.path.join( - sysconfig.get_path("scripts", "venv", {"base": venv_dir}), - "python", - ) - pip_install(python_path, packages) - sys.exit( - subprocess.run( - (python_path, sys.argv[0], *sys.argv[1:]), - ).returncode, - ) - else: + if sys.prefix == venv_dir or SKIP_VENV: + # already in venv or skipping + print("Installing packages...") pip_install(sys.executable, packages) + return + if not os.path.isdir(venv_dir): + print("Creating virtual environment...") + subprocess.run((sys.executable, "-m", "venv", venv_dir), check=True) + python_path = os.path.join( + sysconfig.get_path("scripts", "venv", {"base": venv_dir}), + "python", + ) + sys.exit( + subprocess.run( + (python_path, *sys.argv), + ).returncode, + ) def pip_install(python, args): @@ -149,7 +156,7 @@ def check_coverage(): return ( cov.report() == 100.0 # always succeed on GA - or os.getenv("SKIP_VENV") == "1" + or ON_CI ) From f3428217d3e04bb0a3f9746eed997704c8ef311e Mon Sep 17 00:00:00 2001 From: Krutyi 4el <60041069+Krutyi-4el@users.noreply.github.com> Date: Sat, 12 Aug 2023 12:42:01 +0300 Subject: [PATCH 02/13] add CONTRIBUTING.md --- CONTRIBUTING.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8ea0229 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,24 @@ +I appreciate your desire to contribute to this project + +Here's a basic instruction on how to do it: + +1. Fork my repository on GitHub + +2. Clone your fork locally: + +`git clone https://github.com//i18nice` + +4. `cd` into the folder and install the helper: + +`python dev-helper.py install` + +4. Make changes to the code. +Don't forget to write tests that will cover your changes, add type hints and adhere to flake8 formatting standard. + +5. Commit your changes with proper commit message. +Fix problems identified by the helper if there are any. +You can make several commits. + +6. Push the changes and open pull request to my repository + +If anything is unclear or you just want to report a bug/request a feature, feel free to open an issue. From f3c932981dda1dd8389804c560cdcafbe7999531 Mon Sep 17 00:00:00 2001 From: Krutyi 4el <60041069+Krutyi-4el@users.noreply.github.com> Date: Sat, 12 Aug 2023 16:03:58 +0300 Subject: [PATCH 03/13] make locale and default separate kwargs --- i18n/translator.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/i18n/translator.py b/i18n/translator.py index 0d74771..29598f1 100644 --- a/i18n/translator.py +++ b/i18n/translator.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Union, Tuple, overload +from typing import Any, Dict, Union, Tuple, Optional, overload try: from typing import SupportsIndex except ImportError: @@ -9,18 +9,25 @@ from . import translations, formatters -def t(key: str, **kwargs: Any) -> Union[str, "LazyTranslationTuple"]: - locale = kwargs.pop('locale', None) or config.get('locale') +def t( + key: str, + *, + locale: Optional[str] = None, + default: Optional[str] = None, + **kwargs: Any, +) -> Union[str, "LazyTranslationTuple"]: + if not locale: + locale = config.get("locale") try: - return translate(key, locale=locale, **kwargs) + return translate(key, locale=locale, **kwargs) # type: ignore[arg-type] except KeyError: resource_loader.search_translation(key, locale) if translations.has(key, locale): - return translate(key, locale=locale, **kwargs) + return translate(key, locale=locale, **kwargs) # type: ignore[arg-type] elif locale != config.get('fallback'): return t(key, locale=config.get('fallback'), **kwargs) - if 'default' in kwargs: - return kwargs['default'] + if default is not None: + return default on_missing = config.get('on_missing_translation') if on_missing == "error": raise KeyError('key {0} not found'.format(key)) @@ -63,8 +70,7 @@ def __getitem__(self, key: Union[SupportsIndex, slice]) -> Union[str, Tuple[str, ).format() -def translate(key: str, **kwargs: Any) -> Union[str, LazyTranslationTuple]: - locale = kwargs.pop('locale', None) or config.get('locale') +def translate(key: str, locale: str, **kwargs: Any) -> Union[str, LazyTranslationTuple]: translation = translations.get(key, locale=locale) if isinstance(translation, tuple): return LazyTranslationTuple(key, locale, translation, kwargs) From fef4bb88d29d1ed09691597dc88efbbbad6c0a5e Mon Sep 17 00:00:00 2001 From: Krutyi 4el <60041069+Krutyi-4el@users.noreply.github.com> Date: Sat, 12 Aug 2023 19:56:14 +0300 Subject: [PATCH 04/13] remove native default kwarg --- i18n/tests/translation_tests.py | 3 +++ i18n/translator.py | 3 --- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/i18n/tests/translation_tests.py b/i18n/tests/translation_tests.py index 6c3bda5..8febde0 100644 --- a/i18n/tests/translation_tests.py +++ b/i18n/tests/translation_tests.py @@ -142,6 +142,9 @@ def handler(key, locale, translation, count): self.assertEqual(t('foo.bad_plural', count=1), 'bar elems') def test_default(self): + def return_default(key, locale, **kwargs): + return kwargs.pop("default", key) + config.set("on_missing_translation", return_default) self.assertEqual(t('inexistent_key', default='foo'), 'foo') @unittest.skipUnless(config.json_available, "json library is not available") diff --git a/i18n/translator.py b/i18n/translator.py index 29598f1..5f59b1a 100644 --- a/i18n/translator.py +++ b/i18n/translator.py @@ -13,7 +13,6 @@ def t( key: str, *, locale: Optional[str] = None, - default: Optional[str] = None, **kwargs: Any, ) -> Union[str, "LazyTranslationTuple"]: if not locale: @@ -26,8 +25,6 @@ def t( return translate(key, locale=locale, **kwargs) # type: ignore[arg-type] elif locale != config.get('fallback'): return t(key, locale=config.get('fallback'), **kwargs) - if default is not None: - return default on_missing = config.get('on_missing_translation') if on_missing == "error": raise KeyError('key {0} not found'.format(key)) From fd770c80d35b896ee2f06858ed72153262797c1e Mon Sep 17 00:00:00 2001 From: Krutyi 4el <60041069+Krutyi-4el@users.noreply.github.com> Date: Sat, 12 Aug 2023 20:33:23 +0300 Subject: [PATCH 05/13] add docstrings to public API --- i18n/config.py | 16 +++++++++++++++ i18n/custom_functions.py | 13 ++++++++++++ i18n/errors.py | 5 +++++ i18n/loaders/loader.py | 44 ++++++++++++++++++++++++++++++++++++++++ i18n/resource_loader.py | 28 +++++++++++++++++++++++++ i18n/translations.py | 8 ++++++++ i18n/translator.py | 18 ++++++++++++++++ 7 files changed, 132 insertions(+) diff --git a/i18n/config.py b/i18n/config.py index 4cc8e80..f28f1cb 100644 --- a/i18n/config.py +++ b/i18n/config.py @@ -49,6 +49,14 @@ def set(key: str, value: Any) -> None: + """ + Sets config value + + :param key: Setting to set + :param value: New value + :raises KeyError: If `key` is not a valid key + """ + if key not in settings: raise KeyError("Invalid setting: {0}".format(key)) elif key == 'load_path': @@ -69,6 +77,14 @@ def set(key: str, value: Any) -> None: def get(key: str) -> Any: + """ + Gets config value + + :param key: Setting to get + :return: Associated value + :raises KeyError: If `key` is not a valid key + """ + return settings[key] diff --git a/i18n/custom_functions.py b/i18n/custom_functions.py index a22b2c6..bf53faf 100644 --- a/i18n/custom_functions.py +++ b/i18n/custom_functions.py @@ -8,6 +8,19 @@ def add_function(name: str, func: Function, locale: Optional[str] = None) -> None: + """ + Adds your function to placeholder functions + + Registers the function to locale functions if locale is given + or to global (locale-independent) functions otherwise + The function must accept all kwargs passed to `t` and return an int + (index that will determine which placeholder argument will be used) + + :param name: Name used to register the function + :param func: The function to register + :param locale: Locale to which function will be bound (optional) + """ + if locale: locales_functions[locale][name] = func else: diff --git a/i18n/errors.py b/i18n/errors.py index d1ee0de..684ef14 100644 --- a/i18n/errors.py +++ b/i18n/errors.py @@ -1,4 +1,6 @@ class I18nException(Exception): + """Base class for i18n errors""" + def __init__(self, value: str): self.value = value @@ -7,12 +9,15 @@ def __str__(self): class I18nFileLoadError(I18nException): + """Raised when file load fails""" pass class I18nInvalidStaticRef(I18nException): + """Raised when static reference cannot be resolved""" pass class I18nInvalidFormat(I18nException): + """Raised when provided filename_format is invalid""" pass diff --git a/i18n/loaders/loader.py b/i18n/loaders/loader.py index ae7f80b..c1a1144 100644 --- a/i18n/loaders/loader.py +++ b/i18n/loaders/loader.py @@ -15,6 +15,14 @@ def __init__(self): super(Loader, self).__init__() def load_file(self, filename: str) -> str: + """ + Reads content from file + + :param filename: The file to read + :return: Content of the file + :raises I18nFileLoadError: If loading wasn't successful + """ + try: with io.open(filename, 'r', encoding=config.get('encoding')) as f: return f.read() @@ -24,6 +32,14 @@ def load_file(self, filename: str) -> str: ) from e def parse_file(self, file_content: str) -> dict: + """ + Parses file content to dict. Must be implemented in subclasses + + :param file_content: Content of the file + :return: Parsed content + :raises I18nFileLoadError: If parsing wasn't successful + """ + raise NotImplementedError( "the method parse_file has not been implemented for class {0}".format( self.__class__.__name__, @@ -31,9 +47,25 @@ def parse_file(self, file_content: str) -> dict: ) def check_data(self, data: dict, root_data: Optional[str]) -> bool: + """ + Checks if `root_data` is present in the content + + :param data: Parsed content of the file + :param root_data: Data element to be checked. If `None`, check always succeeds + :return: `True` if `root_data` is present, `False` otherwise + """ + return True if root_data is None else root_data in data def get_data(self, data: dict, root_data: Optional[str]) -> dict: + """ + Extracts `root_data` from `data` + + :param data: Full parsed data + :param root_data: Part of the data to extract + :return: Extracted data + """ + # use .pop to remove used data from cache return data if root_data is None else data.pop(root_data) @@ -43,6 +75,18 @@ def load_resource( root_data: Optional[str], remember_content: bool, ) -> dict: + """ + Main function for resource loading + + Manages caching + + :param filename: File to load + :param root_data: Part of data to extract + :param remember_content: Whether to save other parts of data in cache + :return: Loaded data + :raises I18nFileLoadError: If loading wasn't successful + """ + filename = os.path.abspath(filename) if filename in self.loaded_files: data = self.loaded_files[filename] diff --git a/i18n/resource_loader.py b/i18n/resource_loader.py index 72e1c58..db66f3b 100644 --- a/i18n/resource_loader.py +++ b/i18n/resource_loader.py @@ -11,6 +11,14 @@ def register_loader(loader_class: Type[Loader], supported_extensions: Iterable[str]) -> None: + """ + Registers loader for files + + :param loader_class: Loader to register + :param supported_extensions: Iterable of file extensions that the loader supports + :raises ValueError: If `loader_class` is not a subclass of `i18n.Loader` + """ + if not issubclass(loader_class, Loader): raise ValueError("loader class should be subclass of i18n.Loader") @@ -27,6 +35,8 @@ def load_resource(filename: str, root_data: Optional[str], remember_content: boo def init_loaders(): + """Sets default loaders""" + init_python_loader() if config.yaml_available: init_yaml_loader() @@ -50,6 +60,12 @@ def init_json_loader(): def load_config(filename: str) -> None: + """ + Loads configuration from file + + :param filename: File containing configuration + """ + settings_data = load_resource(filename, "settings") for key, value in settings_data.items(): config.set(key, value) @@ -87,16 +103,28 @@ def load_translation_file(filename: str, base_directory: str, locale: Optional[s def load_everything(locale: Optional[str] = None) -> None: + """ + Loads all translations + + If locale is provided, loads translations only for that locale + + :param locale: Locale (optional) + """ + for directory in config.get("load_path"): recursive_load_everything(directory, "", locale) def unload_everything(): + """Clears all cached translations""" + translations.clear() Loader.loaded_files.clear() def reload_everything(): + """Shortcut for unload_everything() + load_everything()""" + unload_everything() load_everything() diff --git a/i18n/translations.py b/i18n/translations.py index e6f2f99..16fce8b 100644 --- a/i18n/translations.py +++ b/i18n/translations.py @@ -11,6 +11,14 @@ def add( value: TranslationType, locale: Optional[str] = None, ) -> None: + """ + Adds translation to cache + + :param key: Translation key + :param value: Translation + :param locale: Locale (optional). Uses default if not provided + """ + if locale is None: locale = config.get('locale') container.setdefault(locale, {})[key] = value diff --git a/i18n/translator.py b/i18n/translator.py index 5f59b1a..21f6140 100644 --- a/i18n/translator.py +++ b/i18n/translator.py @@ -15,6 +15,24 @@ def t( locale: Optional[str] = None, **kwargs: Any, ) -> Union[str, "LazyTranslationTuple"]: + """ + Main translation function + + Searches for translation in files if it's not already in cache + Tries fallback locale if search fails + If that also fails: + - Returns original key if `on_missing_translation` is not set + - Raises `KeyError` if it's set to `"error"` + - Returns result of calling it if it's set to a function + + :param key: Translation key + :param locale: Locale to translate to (optional) + :param **kwargs: Keyword arguments used to interpolate placeholders + (including `count` for pluralization) + :return: The translation, return value of `on_missing_translation` or the original key + :raises KeyError: If translation wasn't found and `on_missing_translation` is set to `"error"` + """ + if not locale: locale = config.get("locale") try: From 78199bf23f24a0802668ddf2b4f2fe9cc15adce2 Mon Sep 17 00:00:00 2001 From: Krutyi 4el <60041069+Krutyi-4el@users.noreply.github.com> Date: Sun, 13 Aug 2023 13:30:09 +0300 Subject: [PATCH 06/13] add support for None fallback (#17) --- i18n/tests/translation_tests.py | 2 ++ i18n/translator.py | 7 ++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/i18n/tests/translation_tests.py b/i18n/tests/translation_tests.py index 8febde0..9718081 100644 --- a/i18n/tests/translation_tests.py +++ b/i18n/tests/translation_tests.py @@ -81,6 +81,8 @@ def test_locale_change(self): def test_fallback(self): config.set('fallback', 'fr') self.assertEqual(t('foo.hello', name='Bob'), 'Salut Bob !') + config.set("fallback", None) + self.assertEqual(t("foo.hello"), "foo.hello") def test_fallback_from_resource(self): config.set('fallback', 'ja') diff --git a/i18n/translator.py b/i18n/translator.py index 21f6140..d9f3ae6 100644 --- a/i18n/translator.py +++ b/i18n/translator.py @@ -19,7 +19,7 @@ def t( Main translation function Searches for translation in files if it's not already in cache - Tries fallback locale if search fails + Tries fallback locale if search fails and fallback is set If that also fails: - Returns original key if `on_missing_translation` is not set - Raises `KeyError` if it's set to `"error"` @@ -41,8 +41,9 @@ def t( resource_loader.search_translation(key, locale) if translations.has(key, locale): return translate(key, locale=locale, **kwargs) # type: ignore[arg-type] - elif locale != config.get('fallback'): - return t(key, locale=config.get('fallback'), **kwargs) + fallback = config.get("fallback") + if fallback and fallback != locale: + return t(key, locale=fallback, **kwargs) on_missing = config.get('on_missing_translation') if on_missing == "error": raise KeyError('key {0} not found'.format(key)) From 3eb4221cd1590d8451d80d61162b025f8205c2cb Mon Sep 17 00:00:00 2001 From: Krutyi 4el <60041069+Krutyi-4el@users.noreply.github.com> Date: Sun, 13 Aug 2023 14:22:42 +0300 Subject: [PATCH 07/13] enable memoization by default --- i18n/config.py | 2 +- i18n/tests/loader_tests.py | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/i18n/config.py b/i18n/config.py index f28f1cb..d05185f 100644 --- a/i18n/config.py +++ b/i18n/config.py @@ -43,7 +43,7 @@ 'namespace_delimiter': '.', 'plural_few': 5, 'skip_locale_root_data': False, - 'enable_memoization': False, + "enable_memoization": True, 'argument_delimiter': '|' } diff --git a/i18n/tests/loader_tests.py b/i18n/tests/loader_tests.py index ea38c75..b7814ba 100644 --- a/i18n/tests/loader_tests.py +++ b/i18n/tests/loader_tests.py @@ -27,6 +27,7 @@ class TestFileLoader(unittest.TestCase): def setUp(self): resource_loader.loaders = {} translations.container = {} + Loader.loaded_files = {} reload(config) config.set("load_path", [os.path.join(RESOURCE_FOLDER, "translations")]) config.set("filename_format", "{namespace}.{locale}.{format}") @@ -103,6 +104,8 @@ class MyLoader(i18n.loaders.YamlLoader): data = resource_loader.load_resource(file, "settings") self.assertIsInstance(data["maybe_bool"], str) + del Loader.loaded_files[file] + i18n.register_loader(MyLoader, ["yml", "yaml"]) data = resource_loader.load_resource(file, "settings") self.assertIsInstance(data["maybe_bool"], bool) @@ -141,7 +144,8 @@ def test_memoization_with_file(self): Second is a script that will remove the dummy file and load a dict of translations. Then we try to translate inexistent key to ensure that the script is not executed again. """ - config.set("enable_memoization", True) + # should be enabled by default + self.assertTrue(config.get("enable_memoization")) config.set("file_format", "py") resource_loader.init_python_loader() memoization_file_name = 'memoize.en.py' @@ -448,6 +452,7 @@ def test_static_references(self): config.set("load_path", [os.path.join(RESOURCE_FOLDER, "translations")]) config.set("filename_format", "{namespace}.{format}") config.set('skip_locale_root_data', True) + config.set("enable_memoization", False) config.set("locale", "en") self.assertEqual(t("static_ref.welcome"), "Welcome to Programname") From 7883ea79716f3c212b9ef5494bc7bab0deaaee9c Mon Sep 17 00:00:00 2001 From: Krutyi 4el <60041069+Krutyi-4el@users.noreply.github.com> Date: Sun, 13 Aug 2023 18:09:31 +0300 Subject: [PATCH 08/13] add search locking --- i18n/errors.py | 5 +++++ i18n/resource_loader.py | 45 +++++++++++++++++++++++++++++++++----- i18n/tests/loader_tests.py | 30 ++++++++++++++++++++++++- 3 files changed, 74 insertions(+), 6 deletions(-) diff --git a/i18n/errors.py b/i18n/errors.py index 684ef14..c5c2298 100644 --- a/i18n/errors.py +++ b/i18n/errors.py @@ -21,3 +21,8 @@ class I18nInvalidStaticRef(I18nException): class I18nInvalidFormat(I18nException): """Raised when provided filename_format is invalid""" pass + + +class I18nLockedError(I18nException): + """Raised when trying to load locked translations""" + pass diff --git a/i18n/resource_loader.py b/i18n/resource_loader.py index db66f3b..7a4eee6 100644 --- a/i18n/resource_loader.py +++ b/i18n/resource_loader.py @@ -1,8 +1,9 @@ import os.path -from typing import Type, Iterable, Optional, List +from typing import Type, Iterable, Optional, List, Set, Union from . import config from .loaders import Loader, I18nFileLoadError +from .errors import I18nLockedError from . import translations, formatters loaders = {} @@ -102,31 +103,63 @@ def load_translation_file(filename: str, base_directory: str, locale: Optional[s formatters.expand_static_refs(loaded, locale) -def load_everything(locale: Optional[str] = None) -> None: +_locked: Union[bool, Set[Union[str, None]]] = False + + +def _check_locked(locale: Optional[str]) -> bool: + return _locked if isinstance(_locked, bool) else locale in _locked + + +def load_everything(locale: Optional[str] = None, *, lock: bool = False) -> None: """ Loads all translations If locale is provided, loads translations only for that locale :param locale: Locale (optional) + :param lock: Whether to lock translations after loading. + Locking disables further searching for missing translations """ + global _locked + + if _check_locked(locale): + raise I18nLockedError("Translations were locked, use unload_everything() to unlock") + for directory in config.get("load_path"): recursive_load_everything(directory, "", locale) + if not lock: + return + + if locale: + if isinstance(_locked, bool): + _locked = {None, locale} + else: + _locked.add(locale) + else: + _locked = True + def unload_everything(): """Clears all cached translations""" + global _locked + translations.clear() Loader.loaded_files.clear() + _locked = False -def reload_everything(): - """Shortcut for unload_everything() + load_everything()""" +def reload_everything(*, lock: bool = False) -> None: + """ + Shortcut for `unload_everything()` + `load_everything()` + + :param lock: Passed to `load_everything()`, see its description for more information + """ unload_everything() - load_everything() + load_everything(lock=lock) def load_translation_dic(dic: dict, namespace: str, locale: str) -> Iterable[str]: @@ -146,6 +179,8 @@ def load_translation_dic(dic: dict, namespace: str, locale: str) -> Iterable[str def search_translation(key: str, locale: Optional[str] = None) -> None: if locale is None: locale = config.get('locale') + if _check_locked(locale): + return splitted_key = key.split(config.get('namespace_delimiter')) namespace = splitted_key[:-1] for directory in config.get("load_path"): diff --git a/i18n/tests/loader_tests.py b/i18n/tests/loader_tests.py index b7814ba..d70f762 100644 --- a/i18n/tests/loader_tests.py +++ b/i18n/tests/loader_tests.py @@ -12,7 +12,7 @@ import i18n from i18n import resource_loader -from i18n.errors import I18nFileLoadError, I18nInvalidFormat +from i18n.errors import I18nFileLoadError, I18nInvalidFormat, I18nLockedError from i18n.translator import t from i18n import config from i18n.config import json_available, yaml_available @@ -217,6 +217,34 @@ def test_reload_everything(self): i18n.reload_everything() self.assertEqual(t("test.a"), "c") + @unittest.skipUnless(json_available, "json library not available") + def test_load_everything_lock(self): + i18n.load_path[0] = os.path.join(RESOURCE_FOLDER, "translations", "bar") + config.set("file_format", "json") + resource_loader.init_json_loader() + + i18n.load_everything(lock=True) + with self.assertRaises(I18nLockedError): + i18n.load_everything("some_locale") + # unlock + i18n.unload_everything() + + i18n.load_everything("some_locale", lock=True) + with self.assertRaises(I18nLockedError): + i18n.load_everything("some_locale") + with self.assertRaises(I18nLockedError): + i18n.load_everything() + i18n.load_everything("en") + i18n.load_everything("en", lock=True) + with self.assertRaises(I18nLockedError): + i18n.load_everything("en") + + # search should not be performed because we've locked translations + with mock.patch("i18n.resource_loader.recursive_search_dir", side_effect=RuntimeError): + t("abcd") + + i18n.unload_everything() + def test_multilingual_caching(self): resource_loader.init_python_loader() config.set("enable_memoization", True) From 01ed6bcd2234998b411f07c92c31639e719dbabb Mon Sep 17 00:00:00 2001 From: Krutyi 4el <60041069+Krutyi-4el@users.noreply.github.com> Date: Sun, 13 Aug 2023 18:10:50 +0300 Subject: [PATCH 09/13] update readme --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fcb6386..ba5c9d2 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ If you need full yaml functionalities, override it with a custom loader: ### Memoization -Setting the configuration value `enable_memoization` will disable reloading of files every time when searching for missing translation. +The configuration value `enable_memoization` (`True` by default) disables reloading of files every time when searching for missing translation. When translations are loaded, they're always stored in memory, hence it does not affect how existing translations are accessed. ### Load everything @@ -61,6 +61,9 @@ You can call it with locale argument to load only one locale. `i18n.reload_everything()` is just a shortcut for `unload_everything()` followed by `load_everything()`. +For the best performance, you can pass `lock=True` to `load_everything()` to disable searching for missing translations completely. +It'll prevent slowdowns caused by missing translations, but you'll need to use `unload_everything()` to be able to load files again. + ### Namespaces #### File namespaces From ee7488709f28f9e17d503a780820b92bef50621d Mon Sep 17 00:00:00 2001 From: Krutyi 4el <60041069+Krutyi-4el@users.noreply.github.com> Date: Sun, 13 Aug 2023 23:08:34 +0300 Subject: [PATCH 10/13] add CHANGELOG.md --- CHANGELOG.md | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..14a6e15 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,52 @@ +## Note: **(B)** = BREAKING, (pb) = potentially breaking + +### Hint: use `https://github.com/Krutyi-4el/i18nice/compare/v...v` to see full code difference between versions + +### v0.8.1 +- Added flake8 and mypy checks +- Enabled and ensured branch coverage +- Added `__all__` to packages +- Added more type hints +- Fixed static references not expanding +- Fixed strict pluralization with translation tuples +- Created `dev-helper.py` for pre-commit and GA +- Made `PythonLoader` throw more verbose error + +### v0.8.0 +- (pb) `i18n.get("filename_format")` will return `FilenameFormat` instead of string. You can access `template` attribute to get the original string. `set` will continue to work as usual. +- More flexible filename formats +- Type hints for public APIs +- New functions `load_everything` and `unload_everything` +- 100% coverage +- Minor optimizations + +### v0.7.0 +- Added static references feature +- Added full translation list support + +### v0.6.2 +- Added PyPI publishing + +## Note: versions listed below aren't available on PyPI + +### v0.6.0 +- (pb) Switched to `yaml.BaseLoader` + +### v0.5.1 +- Improved memoization (again) + +### v0.5.0 +- Rewrote `PythonLoader` +- **(B)** Removed old `error_on_missing_*` settings +- Improved memoization +- Improved file loading and fixed bugs +- Improved exceptions +- Added `reload_everything()` +- **(B)** Removed deprecated `other` plural + +### v0.4.0 +- Trying to set inexistent setting will now raise KeyError +- Added custom functions +- Fixed settings not updating properly +- **(B)** Dropped Python 2 +- Added `on_missing_*` hooks From 9efb29d08ae23fd02a987da597e8361e15eade5c Mon Sep 17 00:00:00 2001 From: Krutyi 4el <60041069+Krutyi-4el@users.noreply.github.com> Date: Tue, 15 Aug 2023 14:39:23 +0300 Subject: [PATCH 11/13] add __all__ to other files --- .mypy.ini | 2 +- i18n/config.py | 2 ++ i18n/custom_functions.py | 2 ++ i18n/formatters.py | 2 ++ i18n/loaders/__init__.py | 3 ++- i18n/resource_loader.py | 11 +++++++++++ i18n/translations.py | 2 ++ i18n/translator.py | 2 ++ 8 files changed, 24 insertions(+), 2 deletions(-) diff --git a/.mypy.ini b/.mypy.ini index fd90b46..85b1a8f 100644 --- a/.mypy.ini +++ b/.mypy.ini @@ -4,5 +4,5 @@ exclude = build check_untyped_defs = true warn_unused_ignores = true warn_redundant_casts = true +no_implicit_reexport = true disallow_incomplete_defs = true -# no_implicit_reexport = true diff --git a/i18n/config.py b/i18n/config.py index d05185f..7ff2b4f 100644 --- a/i18n/config.py +++ b/i18n/config.py @@ -1,3 +1,5 @@ +__all__ = ("set", "get") + from typing import Any from importlib import reload as _reload diff --git a/i18n/custom_functions.py b/i18n/custom_functions.py index bf53faf..805f9ee 100644 --- a/i18n/custom_functions.py +++ b/i18n/custom_functions.py @@ -1,3 +1,5 @@ +__all__ = ("add_function", "get_function") + from collections import defaultdict from typing import Optional, Callable, Dict diff --git a/i18n/formatters.py b/i18n/formatters.py index c85e1bf..fd2b7c3 100644 --- a/i18n/formatters.py +++ b/i18n/formatters.py @@ -1,3 +1,5 @@ +__all__ = ("TranslationFormatter", "StaticFormatter", "FilenameFormat", "expand_static_refs") + from re import compile, escape try: from re import Match diff --git a/i18n/loaders/__init__.py b/i18n/loaders/__init__.py index 73fc7ac..5b7555a 100644 --- a/i18n/loaders/__init__.py +++ b/i18n/loaders/__init__.py @@ -1,6 +1,7 @@ __all__: tuple = ("Loader", "PythonLoader", "I18nFileLoadError") -from .loader import Loader, I18nFileLoadError +from .loader import Loader +from ..errors import I18nFileLoadError from .python_loader import PythonLoader from .. import config if config.json_available: diff --git a/i18n/resource_loader.py b/i18n/resource_loader.py index 7a4eee6..73645dd 100644 --- a/i18n/resource_loader.py +++ b/i18n/resource_loader.py @@ -1,3 +1,14 @@ +__all__ = ( + "Loader", + "register_loader", + "init_loaders", + "load_config", + "load_everything", + "unload_everything", + "reload_everything", + "search_translation", +) + import os.path from typing import Type, Iterable, Optional, List, Set, Union diff --git a/i18n/translations.py b/i18n/translations.py index 16fce8b..5b38555 100644 --- a/i18n/translations.py +++ b/i18n/translations.py @@ -1,3 +1,5 @@ +__all__ = ("add", "get", "has", "clear") + from typing import Optional, Union, Tuple, Dict from . import config diff --git a/i18n/translator.py b/i18n/translator.py index d9f3ae6..9c7bd6f 100644 --- a/i18n/translator.py +++ b/i18n/translator.py @@ -1,3 +1,5 @@ +__all__ = ("t",) + from typing import Any, Dict, Union, Tuple, Optional, overload try: from typing import SupportsIndex From 62e6b725cca536aff76d7c80115a5168c921fd6d Mon Sep 17 00:00:00 2001 From: Krutyi 4el <60041069+Krutyi-4el@users.noreply.github.com> Date: Tue, 15 Aug 2023 19:19:14 +0300 Subject: [PATCH 12/13] link other documents in README --- README.md | 4 ++++ setup.py | 19 ++++++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ba5c9d2..c29e148 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,10 @@ This library provides i18n functionality for Python 3 out of the box. The usage is mostly based on Rails i18n library. +[CHANGELOG](CHANGELOG.md) + +[CONTRIBUTING](CONTRIBUTING.md) + ## Installation Just run diff --git a/setup.py b/setup.py index cebbb86..a52923d 100644 --- a/setup.py +++ b/setup.py @@ -1,16 +1,29 @@ +import re +from urllib.parse import urljoin + from setuptools import setup + +GITHUB_URL = "https://github.com/Krutyi-4el/i18nice" +long_description = open("README.md").read() +# links on PyPI should have absolute URLs +long_description = re.sub( + r"(\[[^\]]+\]\()((?!https?:)[^\)]+)(\))", + lambda m: m.group(1) + urljoin(GITHUB_URL, "blob/master/" + m.group(2)) + m.group(3), + long_description, +) + setup( name='i18nice', version='0.8.1', description='Translation library for Python', - long_description=open('README.md').read(), + long_description=long_description, long_description_content_type='text/markdown', author='Daniel Perez', author_email='tuvistavie@gmail.com', maintainer="Krutyi 4el", - url='https://github.com/Krutyi-4el/i18nice', - download_url='https://github.com/Krutyi-4el/i18nice/archive/master.zip', + url=GITHUB_URL, + download_url=urljoin(GITHUB_URL, "archive/master.zip"), license='MIT', packages=['i18n', 'i18n.loaders'], zip_safe=True, From a3c1f87304e1f9aace2f59bcc68c1a84ec268f58 Mon Sep 17 00:00:00 2001 From: Krutyi 4el <60041069+Krutyi-4el@users.noreply.github.com> Date: Tue, 15 Aug 2023 19:21:05 +0300 Subject: [PATCH 13/13] bump version --- CHANGELOG.md | 8 ++++++++ setup.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14a6e15..450cc7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ### Hint: use `https://github.com/Krutyi-4el/i18nice/compare/v...v` to see full code difference between versions +### v0.9.0 +- **(B)** Removed `default=` kwarg of `i18n.t()`. You can work around this change with a custom handler [like this](https://github.com/Krutyi-4el/i18nice/blob/01ed6bcd2234998b411f07c92c31639e719dbabb/i18n/tests/translation_tests.py#L147) +- Added docstrings to public API +- Added `__all__` to most files +- Added support for `None` as `fallback` +- (pb) Made memoization enabled by default +- Added `lock=` kwarg to `load_everything()` + ### v0.8.1 - Added flake8 and mypy checks - Enabled and ensured branch coverage diff --git a/setup.py b/setup.py index a52923d..1d54298 100644 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ setup( name='i18nice', - version='0.8.1', + version='0.9.0', description='Translation library for Python', long_description=long_description, long_description_content_type='text/markdown',