diff --git a/i18n/__init__.py b/i18n/__init__.py new file mode 100644 index 0000000..75aa066 --- /dev/null +++ b/i18n/__init__.py @@ -0,0 +1,11 @@ +from . import resource_loader +from .resource_loader import Loader, I18nFileLoadError, register_loader, load_config, reload_everything +from .translator import t +from .translations import add as add_translation +from .custom_functions import add_function +from . import config +from .config import set, get + +resource_loader.init_loaders() + +load_path = config.get('load_path') diff --git a/i18n/config.py b/i18n/config.py new file mode 100644 index 0000000..300e925 --- /dev/null +++ b/i18n/config.py @@ -0,0 +1,57 @@ +try: + __import__("yaml") + yaml_available = True +except ImportError: + yaml_available = False + +try: + __import__("json") + json_available = True +except ImportError: + json_available = False + +# try to get existing path object +# in case if config is being reloaded +try: + from . import load_path + load_path.clear() +except ImportError: + load_path = [] + +settings = { + 'filename_format': '{namespace}.{locale}.{format}', + 'file_format': 'yml' if yaml_available else 'json' if json_available else 'py', + 'available_locales': ['en'], + 'load_path': load_path, + 'locale': 'en', + 'fallback': 'en', + 'placeholder_delimiter': '%', + 'on_missing_translation': None, + 'on_missing_placeholder': None, + 'on_missing_plural': None, + 'encoding': 'utf-8', + 'namespace_delimiter': '.', + 'plural_few': 5, + 'skip_locale_root_data': False, + 'enable_memoization': False, + 'argument_delimiter': '|' +} + +def set(key, value): + if key not in settings: + raise KeyError("Invalid setting: {0}".format(key)) + if key == 'placeholder_delimiter': + # hacky trick to reload formatter's configuration + from .translator import TranslationFormatter + + TranslationFormatter.delimiter = value + del TranslationFormatter.pattern + TranslationFormatter.__init_subclass__() + elif key == 'load_path': + load_path.clear() + load_path.extend(value) + return + settings[key] = value + +def get(key): + return settings[key] diff --git a/i18n/custom_functions.py b/i18n/custom_functions.py new file mode 100644 index 0000000..d0d154c --- /dev/null +++ b/i18n/custom_functions.py @@ -0,0 +1,17 @@ +from collections import defaultdict + + +global_functions = {} +locales_functions = defaultdict(dict) + + +def add_function(name, func, locale=None): + if locale: + locales_functions[locale][name] = func + else: + global_functions[name] = func + +def get_function(name, locale=None): + if locale and name in locales_functions[locale]: + return locales_functions[locale][name] + return global_functions.get(name) diff --git a/i18n/loaders/__init__.py b/i18n/loaders/__init__.py new file mode 100644 index 0000000..81c323b --- /dev/null +++ b/i18n/loaders/__init__.py @@ -0,0 +1,9 @@ +from .loader import Loader, I18nFileLoadError +from .python_loader import PythonLoader +from .. import config +if config.json_available: + from .json_loader import JsonLoader +if config.yaml_available: + from .yaml_loader import YamlLoader + +del config diff --git a/i18n/loaders/json_loader.py b/i18n/loaders/json_loader.py new file mode 100644 index 0000000..2e732b3 --- /dev/null +++ b/i18n/loaders/json_loader.py @@ -0,0 +1,14 @@ +import json + +from . import Loader, I18nFileLoadError + +class JsonLoader(Loader): + """class to load json files""" + def __init__(self): + super(JsonLoader, self).__init__() + + def parse_file(self, file_content): + try: + return json.loads(file_content) + except json.JSONDecodeError as e: + raise I18nFileLoadError("invalid JSON: {0}".format(e.args[0])) from e diff --git a/i18n/loaders/loader.py b/i18n/loaders/loader.py new file mode 100644 index 0000000..555cbe9 --- /dev/null +++ b/i18n/loaders/loader.py @@ -0,0 +1,57 @@ +from .. import config +import io +import os.path + + +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""" + + loaded_files = {} + + def __init__(self): + super(Loader, self).__init__() + + def load_file(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)) from e + + 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): + # use .pop to remove used data from cache + return data if root_data is None else data.pop(root_data) + + def load_resource(self, filename, root_data, remember_content): + filename = os.path.abspath(filename) + if filename in self.loaded_files: + data = self.loaded_files[filename] + if not data: + # cache is missing or exhausted + return {} + else: + 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)) + enable_memoization = config.get('enable_memoization') + if enable_memoization: + if remember_content: + self.loaded_files[filename] = data + else: + self.loaded_files[filename] = None + return self.get_data(data, root_data) diff --git a/i18n/loaders/python_loader.py b/i18n/loaders/python_loader.py new file mode 100644 index 0000000..00fd5ac --- /dev/null +++ b/i18n/loaders/python_loader.py @@ -0,0 +1,27 @@ +import os.path +import sys +from importlib import util + +from .. import config +from . import Loader, I18nFileLoadError + + +class PythonLoader(Loader): + """class to load python files""" + + def __init__(self): + super(PythonLoader, self).__init__() + + def load_file(self, filename): + _, name = os.path.split(filename) + module_name, _ = os.path.splitext(name) + try: + spec = util.spec_from_file_location(module_name, filename) + module = util.module_from_spec(spec) + spec.loader.exec_module(module) + return vars(module) + except Exception as e: + raise I18nFileLoadError("error loading file {0}".format(filename)) from e + + def parse_file(self, file_content): + return file_content diff --git a/i18n/loaders/yaml_loader.py b/i18n/loaders/yaml_loader.py new file mode 100644 index 0000000..4fa4412 --- /dev/null +++ b/i18n/loaders/yaml_loader.py @@ -0,0 +1,18 @@ +import yaml + +from . import Loader, I18nFileLoadError + + +class YamlLoader(Loader): + """class to load yaml files""" + + loader = yaml.BaseLoader + + def __init__(self): + super(YamlLoader, self).__init__() + + def parse_file(self, file_content): + try: + return yaml.load(file_content, Loader=self.loader) + except yaml.YAMLError as e: + raise I18nFileLoadError("invalid YAML: {0}".format(str(e))) from e diff --git a/i18n/resource_loader.py b/i18n/resource_loader.py new file mode 100644 index 0000000..dd6572e --- /dev/null +++ b/i18n/resource_loader.py @@ -0,0 +1,127 @@ +import os.path + +from . import config +from .loaders import Loader, I18nFileLoadError +from . import translations + +loaders = {} + +PLURALS = {"zero", "one", "few", "many"} + + +def register_loader(loader_class, supported_extensions): + if not issubclass(loader_class, Loader): + raise ValueError("loader class should be subclass of i18n.Loader") + + loader = loader_class() + for extension in supported_extensions: + loaders[extension] = loader + + +def load_resource(filename, root_data, remember_content=False): + extension = os.path.splitext(filename)[1][1:] + if extension not in loaders: + raise I18nFileLoadError("no loader available for extension {0}".format(extension)) + return loaders[extension].load_resource(filename, root_data, remember_content) + + +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 import PythonLoader + register_loader(PythonLoader, ["py"]) + + +def init_yaml_loader(): + from .loaders import YamlLoader + register_loader(YamlLoader, ["yml", "yaml"]) + + +def init_json_loader(): + from .loaders import JsonLoader + register_loader(JsonLoader, ["json"]) + + +def load_config(filename): + settings_data = load_resource(filename, "settings") + for key, value in settings_data.items(): + config.set(key, value) + + +def get_namespace_from_filepath(filename): + namespace = os.path.dirname(filename).strip(os.sep).replace(os.sep, config.get('namespace_delimiter')) + format = config.get('filename_format') + if '{namespace}' in format: + try: + splitted_filename = os.path.basename(filename).split('.') + if namespace: + namespace += config.get('namespace_delimiter') + namespace += splitted_filename[format.split(".").index('{namespace}')] + except ValueError as e: + raise I18nFileLoadError("incorrect file format.") from e + return namespace + + +def load_translation_file(filename, base_directory, locale=None): + if locale is None: + locale = config.get('locale') + skip_locale_root_data = config.get('skip_locale_root_data') + root_data = None if skip_locale_root_data else locale + # if the file isn't dedicated to one locale and may contain other `root_data`s + remember_content = "{locale}" not in config.get("filename_format") and root_data + translations_dic = load_resource(os.path.join(base_directory, filename), root_data, remember_content) + namespace = get_namespace_from_filepath(filename) + load_translation_dic(translations_dic, namespace, locale) + + +def reload_everything(): + translations.clear() + Loader.loaded_files.clear() + + +def load_translation_dic(dic, namespace, locale): + if namespace: + namespace += config.get('namespace_delimiter') + for key, value in dic.items(): + if type(value) == dict and len(PLURALS.intersection(value)) < 2: + load_translation_dic(value, namespace + key, locale) + else: + translations.add(namespace + key, value, locale) + + +def load_directory(directory, 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 not locale in f: + continue + load_translation_file(f, directory, locale) + + +def search_translation(key, locale=None): + if locale is None: + locale = config.get('locale') + splitted_key = key.split(config.get('namespace_delimiter')) + 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): + namespace = splitted_namespace[0] if splitted_namespace else "" + seeked_file = config.get('filename_format').format(namespace=namespace, 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 namespace in dir_content: + recursive_search_dir(splitted_namespace[1:], os.path.join(directory, namespace), root_dir, locale) diff --git a/i18n/translations.py b/i18n/translations.py new file mode 100644 index 0000000..e2af7c1 --- /dev/null +++ b/i18n/translations.py @@ -0,0 +1,28 @@ +from . import config + +container = {} + + +def add(key, value, locale=None): + if locale is None: + locale = config.get('locale') + container.setdefault(locale, {})[key] = value + + +def has(key, locale=None): + if locale is None: + locale = config.get('locale') + return key in container.get(locale, {}) + + +def get(key, locale=None): + if locale is None: + locale = config.get('locale') + return container[locale][key] + + +def clear(locale=None): + if locale is None: + container.clear() + elif locale in container: + container[locale].clear() diff --git a/i18n/translator.py b/i18n/translator.py new file mode 100644 index 0000000..104111f --- /dev/null +++ b/i18n/translator.py @@ -0,0 +1,115 @@ +from string import Template + +from . import config +from . import resource_loader +from . import translations +from .custom_functions import get_function + + +class TranslationFormatter(Template, dict): + delimiter = config.get('placeholder_delimiter') + idpattern = r""" + \w+ # name + ( + \( + [^\(\){}]* # arguments + \) + )? + """ + + def __init__(self, translation_key, template): + super(TranslationFormatter, self).__init__(template) + self.translation_key = translation_key + + def format(self, locale, **kwargs): + self.clear() + self.update(kwargs) + self.locale = locale + if config.get('on_missing_placeholder'): + return self.substitute(self) + else: + return self.safe_substitute(self) + + def __getitem__(self, key: str): + try: + name, _, args = key.partition("(") + if args: + f = get_function(name, self.locale) + if f: + i = f(**self) + args = args.strip(')').split(config.get('argument_delimiter')) + try: + return args[i] + except (IndexError, TypeError) as e: + raise ValueError( + "No argument {0!r} for function {1!r} (in {2!r})" + .format(i, name, self.template) + ) from e + raise KeyError( + "No function {0!r} found for locale {1!r} (in {2!r})" + .format(name, self.locale, self.template) + ) + return super().__getitem__(key) + except KeyError: + on_missing = config.get('on_missing_placeholder') + if not on_missing or on_missing == "error": + raise + return on_missing(self.translation_key, self.locale, self.template, key) + + +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'] + on_missing = config.get('on_missing_translation') + if on_missing == "error": + raise KeyError('key {0} not found'.format(key)) + elif on_missing: + return on_missing(key, locale, **kwargs) + 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, locale, translation, kwargs['count']) + return TranslationFormatter(key, translation).format(locale, **kwargs) + + +def pluralize(key, locale, translation, count): + return_value = key + try: + if type(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'] + if 'many' in translation: + return translation['many'] + else: + raise KeyError('"many" not defined for key {0}'.format(key)) + except KeyError: + on_missing = config.get('on_missing_plural') + if on_missing == "error": + raise + elif on_missing: + return on_missing(key, locale, translation, count) + else: + return return_value diff --git a/npbackup/core/i18n_helper.py b/npbackup/core/i18n_helper.py index 57c072c..e73585c 100644 --- a/npbackup/core/i18n_helper.py +++ b/npbackup/core/i18n_helper.py @@ -51,5 +51,3 @@ def _t(*args, **kwargs): logger.error("Arguments: {}".format(*args)) if len(args) > 0: return args[0] - - diff --git a/npbackup/requirements.txt b/npbackup/requirements.txt index 8d21e97..6adf5bc 100644 --- a/npbackup/requirements.txt +++ b/npbackup/requirements.txt @@ -13,10 +13,12 @@ requests ruamel.yaml psutil pyyaml # Required for python-i18n which does not work with ruamel.yaml -python-i18n +# python-i18n # Replaced python-i18n with a fork that prevents boolean keys from being interpreted +# Also fixes some portability issues (still boolean key name issues) encountered when compiling on Centos 7 and executing on Almalinux 9 # python-i18n@https://github.com/Krutyi-4el/python-i18n/archive/master.zip # python-i18n @ git+https://github.com/Krutyi-4el/python-i18n.git@0.6.0#8999a0d380be8a08beed785e46fbb31dfc03c605 -# Since PyPI and twine don't allow usage of direct references (git addresses), we'll keep the initial version for now +# Since PyPI and twine don't allow usage of direct references (git addresses) +# we'll use an inline version for now packaging pywin32; platform_system == "Windows" \ No newline at end of file