bedrock/lib/l10n_utils/fluent.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

268 строки
7.7 KiB
Python
Исходник Обычный вид История

# 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"""
2021-09-30 19:44:54 +03:00
@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"""
2021-09-30 19:44:54 +03:00
@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"""
2021-09-30 19:44:54 +03:00
@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(True)
l10n = fluent_l10n([locale, "en"], 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.
"""
Lay in Pocket l10n workflow support (#11576) * [Tiny] Improve error messaging if git repo interaction fails * Override the Fluent repo-related config in Pocket mode Note that for Pocket mode, we don't need an intermediary mozmeao/* repo to hold the translations while we calculate activation metadata via a CI task (which does happen for Mozorg). Why? All locales in Pocket should be 100% ready to go, because they are translated by a vendor, whereas Mozorg has community translation contributions as well, which aren't always exhaustive. As a result, both FLUENT_REPO and FLUENT_L10N_TEAM_REPO point to the same repo * Update Bedrock CI step to try to open a PR against the l10n repo ...if there are changes to the l10n files. Note that we try to update both www-l10n (Mozorg) and pocket-www-l10n (Pocket) each time it's run, with the plan that if it's run against an irrelevant repo no changes will be detected, even though it's running twice (This may need tweaking once it's running in CI properly, of course) * Update settings.get_dev_languages to take params, to make re-use elsewhere easier * Initial pass at setting up DEV and PROD languages for Pocket mode * Ensure Pocket always uses all available production langs All locales in Pocket should be 100% ready to go, because they are translated by a vendor, whereas Mozorg has community translation contributions as well, which aren't always exhaustive. * Wind back "Update settings.get_dev_languages to take params, to make re-use elsewhere easier" This reverts some of the work done in c653640ae56cf21e961c461a421d6dd5fa54efcd. * Make DEV_LANGUAGES the same as PROD_LANGUAGES as there's no need not to * Ensure Pocket-related l10n files are copied into Dockerimage during build * Make the l10n_update command pull from both Mozorg and Pocket Fluent repos * Split out pocket-l10n PR step in CI, so that Mozorg and Pocket steps run separately * Update LANGUAGE_CODE for Pocket mode to be just 'en' * Update output messaging to help keep track of FTL updates * Nitfix: make env setting in Docker consistent * Tidy up l10n-related settings following code review * Nit: updating comments * Fixup: Add quotes around bash variable substitution * Tweak locale fallbacks * Update locale setup for Spanish, but more work needed Just noting some complexity to unpick here. getpocket.com/es-la/ exists, and so does getpocket.com/es/. However it is sounding like /es/ needs to use `es-ES` content from the vendor and /es-la/ needs to use `es` from the vendor. Also note that Pocket currently uses `/es-la/` not `/es-LA/` This all may require some extra wiring, as it feels like it's flipping over the behaviour we have in bedrock. * Move non-official es-la locale to FALLBACK_LOCALES * Drop es-MX, es-CL, es-AR from FALLBACK_LOCALES - unnecessary If the language_REGION locale string doesn't map to a specific locale, we try to map the two-letter locale, so we don't need the variant locales for es. * Drop unnecessary inclusion of en-* locale variants -- they'll fall back to /en/ fine without config
2022-05-11 13:32:38 +03:00
if settings.IS_POCKET_MODE:
# All locales in Pocket should be 100% ready to go, because they are
# translated by a vendor, so we can just use Prod languages all the time
return settings.PROD_LANGUAGES
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(True)
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 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)