lib/l10n_utils/fluent.py (172 lines of code) (raw):

# This Source Code Form is subject to the terms of the Mozilla Public # License, v. 2.0. If a copy of the MPL was not distributed with this # file, You can obtain one at https://mozilla.org/MPL/2.0/. import json import re from functools import wraps from hashlib import md5 from django.conf import settings from django.core.cache import caches from django.utils.encoding import force_bytes from django.utils.functional import cached_property, lazy from fluent.runtime import FluentLocalization, FluentResource from fluent.syntax.ast import GroupComment, Message from lib.l10n_utils import translation __all__ = [ "fluent_l10n", "ftl", "ftl_file_is_active", "ftl_has_messages", "ftl_lazy", "get_metadata_file_path", "has_messages", "translate", ] cache = caches["fluent"] REQUIRED_RE = re.compile(r"^required\b", re.MULTILINE | re.IGNORECASE) TERM_RE = re.compile(r"\{\s*-[a-z-]+\s*\}") class FluentL10n(FluentLocalization): def _localized_bundles(self): for bundle in self._bundles(): if bundle.locales[0] == self.locales[0]: yield bundle @cached_property def _message_ids(self): messages = set() for bundle in self._bundles(): messages.update(bundle._messages.keys()) return list(messages) @cached_property def _localized_message_ids(self): messages = set() for bundle in self._localized_bundles(): messages.update(bundle._messages.keys()) return list(messages) @cached_property def required_message_ids(self): """ Look in the "en" file for message IDs grouped by a comment that starts with "Required" :return: list of message IDs """ messages = set() for resources in self.resource_loader.resources("en", self.resource_ids): for resource in resources: in_required = False for item in resource.body: if isinstance(item, GroupComment): in_required = REQUIRED_RE.search(item.content) continue if isinstance(item, Message) and in_required: messages.add(item.id.name) return list(messages) @cached_property def has_required_messages(self): return all(self.has_message(m) for m in self.required_message_ids) @cached_property def active_locales(self): # first resource is the one to check for activation return get_active_locales(self.resource_ids[0]) @cached_property def percent_translated(self): if not self._message_ids: return 0 return (float(len(self._localized_message_ids)) / float(len(self._message_ids))) * 100 def has_message(self, message_id): # assume English locales have the message if self.locales[0].startswith("en-"): return True return message_id in self._localized_message_ids class FluentResourceLoader: """A resource loader that will add english brand terms to every bundle""" @staticmethod def resources(locale, resource_ids): for root in settings.FLUENT_PATHS: resources = load_fluent_resources(root, locale, resource_ids) if resources: yield resources def _cache_key(*args, **kwargs): key = f"fluent:{args}:{kwargs}" return md5(force_bytes(key)).hexdigest() def memoize(f): """Decorator to cache the results of expensive functions""" @wraps(f) def inner(*args, **kwargs): key = _cache_key(f.__name__, *args, **kwargs) value = cache.get(key) if value is None: value = f(*args, **kwargs) cache.set(key, value) return value return inner def l10nize(f): """Decorator to create and pass in the l10n object""" @wraps(f) def inner(*args, **kwargs): ftl_files = kwargs.get("ftl_files", []) if isinstance(ftl_files, str): ftl_files = [ftl_files] elif isinstance(ftl_files, tuple): ftl_files = list(ftl_files) # can not use += here because that mutates the original list ftl_files = ftl_files + settings.FLUENT_DEFAULT_FILES locale = kwargs.get("locale") or translation.get_language() locales = [locale] if locale != "en": locales.append("en") l10n = fluent_l10n(locales, ftl_files) return f(l10n, *args, **kwargs) return inner @memoize def load_fluent_resources(root, locale, resource_ids): resources = [] for resource_id in resource_ids: path = root.joinpath(locale, resource_id) if not path.is_file(): continue resources.append(load_fluent_file(path)) if resources: if locale != "en": path = settings.FLUENT_LOCAL_PATH.joinpath("en", "brands.ftl") resources.append(load_fluent_file(path)) return resources @memoize def load_fluent_file(path): with path.open(encoding="utf-8") as ftl_file: return FluentResource(ftl_file.read()) def get_metadata_file_path(ftl_file): return settings.FLUENT_REPO_PATH.joinpath("metadata", ftl_file).with_suffix(".json") def get_metadata(ftl_file): path = get_metadata_file_path(ftl_file) try: with path.open() as mdf: return json.load(mdf) except (OSError, ValueError): return {} def write_metadata(ftl_file, data): metadata_path = get_metadata_file_path(ftl_file) if not metadata_path.exists(): metadata_path.parent.mkdir(parents=True, exist_ok=True) with metadata_path.open("w") as mdf: json.dump(data, mdf, indent=2, sort_keys=True) @memoize def get_active_locales(ftl_files, force=False): """Return the list of active locales for a Fluent file. If `settings.DEV` is `True` it will just return the full list of available languages. You can pass `force=True` to override this behavior. """ if settings.DEV and not force: return settings.DEV_LANGUAGES if isinstance(ftl_files, str): ftl_files = [ftl_files] locales = {settings.LANGUAGE_CODE} for ftl_file in ftl_files: file_locales = set() metadata = get_metadata(ftl_file) if metadata and "active_locales" in metadata: file_locales.update(metadata["active_locales"]) i_locales = metadata.get("inactive_locales") if i_locales: file_locales.difference_update(i_locales) locales.update(file_locales) return sorted(locales) def ftl_file_is_active(ftl_file, locale=None): """Return True if the given FTL file is active in the given locale.""" locale = locale or translation.get_language() return locale in get_active_locales(ftl_file) def fluent_l10n(locales, files): if isinstance(locales, str): locales = [locales] # file IDs may not have file extension files = [f"{f}.ftl" if not f.endswith(".ftl") else f for f in files] return FluentL10n(locales, files, FluentResourceLoader) def ftl_has_messages(l10n, *message_ids, require_all=True): test = all if require_all else any return test([l10n.has_message(mid) for mid in message_ids]) def translate(l10n, message_id, fallback=None, **kwargs): # check the `locale` bundle for the message if we have a fallback defined if fallback and l10n.has_message(fallback) and not l10n.has_message(message_id): message_id = fallback return l10n.format_value(message_id, kwargs) # View Utils # for use in python views has_messages = l10nize(ftl_has_messages) ftl = l10nize(translate) # for use in python outside of a view ftl_lazy = lazy(ftl, str)