"""
Localizers provide a wide range of localization utilities through a single entry point.
"""
from __future__ import annotations
import datetime
import gettext
from collections import defaultdict
from contextlib import suppress
from typing import final, Mapping, Iterator, AsyncIterator, TYPE_CHECKING
import aiofiles
from babel import dates
from babel.dates import format_date
from betty import fs
from betty.concurrent import Lock, AsynchronizedLock
from betty.hashid import hashid_file_meta
from betty.locale import (
get_data,
to_babel_identifier,
DEFAULT_LOCALE,
Localey,
to_locale,
negotiate_locale,
)
from betty.locale.babel import run_babel
from betty.date import (
DatePartsFormatters,
DateFormatters,
DateRangeFormatters,
Datey,
Date,
IncompleteDateError,
DateRange,
)
from polib import pofile
if TYPE_CHECKING:
from collections.abc import MutableMapping
from betty.assets import AssetRepository
from pathlib import Path
[docs]
class Localizer:
"""
Provide localization functionality for a specific locale.
"""
[docs]
def __init__(self, locale: str, translations: gettext.NullTranslations):
self._locale = locale
self._locale_data = get_data(locale)
self._translations = translations
self.__date_parts_formatters: DatePartsFormatters | None = None
self.__date_formatters: DateFormatters | None = None
self.__date_range_formatters: DateRangeFormatters | None = None
@property
def locale(self) -> str:
"""
The locale.
"""
return self._locale
def _(self, message: str) -> str:
"""
Like :py:meth:`gettext.gettext`.
Arguments are identical to those of :py:meth:`gettext.gettext`.
"""
return self._translations.gettext(message)
[docs]
def gettext(self, message: str) -> str:
"""
Like :py:meth:`gettext.gettext`.
Arguments are identical to those of :py:meth:`gettext.gettext`.
"""
return self._translations.gettext(message)
[docs]
def ngettext(self, message_singular: str, message_plural: str, n: int) -> str:
"""
Like :py:meth:`gettext.ngettext`.
Arguments are identical to those of :py:meth:`gettext.ngettext`.
"""
return self._translations.ngettext(message_singular, message_plural, n)
[docs]
def pgettext(self, context: str, message: str) -> str:
"""
Like :py:meth:`gettext.pgettext`.
Arguments are identical to those of :py:meth:`gettext.pgettext`.
"""
return self._translations.pgettext(context, message)
[docs]
def npgettext(
self, context: str, message_singular: str, message_plural: str, n: int
) -> str:
"""
Like :py:meth:`gettext.npgettext`.
Arguments are identical to those of :py:meth:`gettext.npgettext`.
"""
return self._translations.npgettext(
context, message_singular, message_plural, n
)
@property
def _date_parts_formatters(self) -> DatePartsFormatters:
if self.__date_parts_formatters is None:
self.__date_parts_formatters = {
(True, True, True): self._("MMMM d, y"),
(True, True, False): self._("MMMM, y"),
(True, False, False): self._("y"),
(False, True, True): self._("MMMM d"),
(False, True, False): self._("MMMM"),
}
return self.__date_parts_formatters
@property
def _date_formatters(self) -> DateFormatters:
if self.__date_formatters is None:
self.__date_formatters = {
(True,): self._("around {date}"),
(False,): self._("{date}"),
}
return self.__date_formatters
@property
def _date_range_formatters(self) -> DateRangeFormatters:
if self.__date_range_formatters is None:
self.__date_range_formatters = {
(False, False, False, False): self._(
"from {start_date} until {end_date}"
),
(False, False, False, True): self._(
"from {start_date} until sometime before {end_date}"
),
(False, False, True, False): self._(
"from {start_date} until around {end_date}"
),
(False, False, True, True): self._(
"from {start_date} until sometime before around {end_date}"
),
(False, True, False, False): self._(
"from sometime after {start_date} until {end_date}"
),
(False, True, False, True): self._(
"sometime between {start_date} and {end_date}"
),
(False, True, True, False): self._(
"from sometime after {start_date} until around {end_date}"
),
(False, True, True, True): self._(
"sometime between {start_date} and around {end_date}"
),
(True, False, False, False): self._(
"from around {start_date} until {end_date}"
),
(True, False, False, True): self._(
"from around {start_date} until sometime before {end_date}"
),
(True, False, True, False): self._(
"from around {start_date} until around {end_date}"
),
(True, False, True, True): self._(
"from around {start_date} until sometime before around {end_date}"
),
(True, True, False, False): self._(
"from sometime after around {start_date} until {end_date}"
),
(True, True, False, True): self._(
"sometime between around {start_date} and {end_date}"
),
(True, True, True, False): self._(
"from sometime after around {start_date} until around {end_date}"
),
(True, True, True, True): self._(
"sometime between around {start_date} and around {end_date}"
),
(False, False, None, None): self._("from {start_date}"),
(False, True, None, None): self._("sometime after {start_date}"),
(True, False, None, None): self._("from around {start_date}"),
(True, True, None, None): self._("sometime after around {start_date}"),
(None, None, False, False): self._("until {end_date}"),
(None, None, False, True): self._("sometime before {end_date}"),
(None, None, True, False): self._("until around {end_date}"),
(None, None, True, True): self._("sometime before around {end_date}"),
}
return self.__date_range_formatters
def _format_date_parts(self, date: Date | None) -> str:
if date is None:
raise IncompleteDateError("This date is None.")
try:
date_parts_format = self._date_parts_formatters[
tuple(
(x is not None for x in date.parts), # type: ignore[index]
)
]
except KeyError:
raise IncompleteDateError(
"This date does not have enough parts to be rendered."
) from None
parts = (1 if x is None else x for x in date.parts)
return dates.format_date(
datetime.date(*parts), date_parts_format, self._locale_data
)
DEFAULT_LOCALIZER = Localizer(DEFAULT_LOCALE, gettext.NullTranslations())
[docs]
@final
class LocalizerRepository:
"""
Exposes the available localizers.
"""
[docs]
def __init__(self, assets: AssetRepository):
self._assets = assets
self._localizers: MutableMapping[str, Localizer] = {}
self._locks: Mapping[str, Lock] = defaultdict(AsynchronizedLock.threading)
self._locales: set[str] | None = None
@property
def locales(self) -> Iterator[str]:
"""
The available locales.
"""
if self._locales is None:
self._locales = set()
self._locales.add(DEFAULT_LOCALE)
for assets_directory_path in reversed(self._assets.assets_directory_paths):
for po_file_path in assets_directory_path.glob("locale/*/betty.po"):
self._locales.add(po_file_path.parent.name)
yield from self._locales
[docs]
async def get(self, locale: Localey) -> Localizer:
"""
Get the localizer for the given locale.
"""
locale = to_locale(locale)
async with self._locks[locale]:
try:
return self._localizers[locale]
except KeyError:
return await self._build_translation(locale)
[docs]
async def get_negotiated(self, *preferred_locales: str) -> Localizer:
"""
Get the best matching available locale for the given preferred locales.
"""
preferred_locales = (*preferred_locales, DEFAULT_LOCALE)
negotiated_locale = negotiate_locale(preferred_locales, list(self.locales))
return await self.get(negotiated_locale or DEFAULT_LOCALE)
async def _build_translation(self, locale: str) -> Localizer:
translations = gettext.NullTranslations()
for assets_directory_path in reversed(self._assets.assets_directory_paths):
opened_translations = await self._open_translations(
locale, assets_directory_path
)
if opened_translations:
opened_translations.add_fallback(translations)
translations = opened_translations
self._localizers[locale] = Localizer(locale, translations)
return self._localizers[locale]
async def _open_translations(
self, locale: str, assets_directory_path: Path
) -> gettext.GNUTranslations | None:
po_file_path = assets_directory_path / "locale" / locale / "betty.po"
try:
translation_version = await hashid_file_meta(po_file_path)
except FileNotFoundError:
return None
cache_directory_path = (
fs.HOME_DIRECTORY_PATH / "cache" / "locale" / translation_version
)
mo_file_path = cache_directory_path / "betty.mo"
with suppress(FileNotFoundError), open(mo_file_path, "rb") as f:
return gettext.GNUTranslations(f)
cache_directory_path.mkdir(exist_ok=True, parents=True)
await run_babel(
"",
"compile",
"-i",
str(po_file_path),
"-o",
str(mo_file_path),
"-l",
str(get_data(locale)),
"-D",
"betty",
)
with open(mo_file_path, "rb") as f:
return gettext.GNUTranslations(f)
[docs]
async def coverage(self, locale: Localey) -> tuple[int, int]:
"""
Get the translation coverage for the given locale.
:return: A 2-tuple of the number of available translations and the
number of translatable source strings.
"""
translatables = {
translatable async for translatable in self._get_translatables()
}
locale = to_locale(locale)
if locale == DEFAULT_LOCALE:
return len(translatables), len(translatables)
translations = {
translation async for translation in self._get_translations(locale)
}
return len(translations), len(translatables)
async def _get_translatables(self) -> AsyncIterator[str]:
for assets_directory_path in self._assets.assets_directory_paths:
with suppress(FileNotFoundError):
async with aiofiles.open(
assets_directory_path / "locale" / "betty.pot"
) as pot_data_f:
pot_data = await pot_data_f.read()
for entry in pofile(pot_data):
yield entry.msgid_with_context
async def _get_translations(self, locale: str) -> AsyncIterator[str]:
for assets_directory_path in reversed(self._assets.assets_directory_paths):
with suppress(FileNotFoundError):
async with aiofiles.open(
assets_directory_path / "locale" / locale / "betty.po",
encoding="utf-8",
) as po_data_f:
po_data = await po_data_f.read()
for entry in pofile(po_data):
if entry.translated():
yield entry.msgid_with_context