Source code for betty.locale.localizer

"""
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
[docs] def format_datey(self, date: Datey) -> str: """ Format a datey value into a human-readable string. """ if isinstance(date, Date): return self.format_date(date) return self.format_date_range(date)
[docs] def format_date(self, date: Date) -> str: """ Format a date to a human-readable string. """ try: return self._date_formatters[(date.fuzzy,)].format( date=self._format_date_parts(date), ) except IncompleteDateError: return self._("unknown date")
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 )
[docs] def format_date_range(self, date_range: DateRange) -> str: """ Format a date range to a human-readable string. """ formatter_configuration: tuple[ bool | None, bool | None, bool | None, bool | None ] = (None, None, None, None) formatter_arguments = {} with suppress(IncompleteDateError): formatter_arguments["start_date"] = self._format_date_parts( date_range.start ) formatter_configuration = ( None if date_range.start is None else date_range.start.fuzzy, date_range.start_is_boundary, formatter_configuration[2], formatter_configuration[3], ) with suppress(IncompleteDateError): formatter_arguments["end_date"] = self._format_date_parts(date_range.end) formatter_configuration = ( formatter_configuration[0], formatter_configuration[1], None if date_range.end is None else date_range.end.fuzzy, date_range.end_is_boundary, ) if not formatter_arguments: raise IncompleteDateError( "This date range does not have enough parts to be rendered." ) return self._date_range_formatters[formatter_configuration].format( **formatter_arguments )
[docs] def format_datetime_datetime(self, datetime_datetime: datetime.datetime) -> str: """ Format a datetime date to a human-readable string. """ return format_date( datetime_datetime, "long", locale=to_babel_identifier(self.locale) )
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